A strongly-typed functional programming language that compiles to JavaScript.
Zoya combines Rust-inspired syntax with Hindley-Milner type inference, giving you the safety of static types without the verbosity of explicit annotations everywhere.
struct Point { x: Int, y: Int }
fn distance(Point { x, y }: Point) -> Float {
let squared = x * x + y * y;
squared.to_float().sqrt()
}
fn main() -> Float {
let origin = Point { x: 3, y: 4 };
distance(origin)
}
- Type inference - Types are inferred automatically; annotations optional
- Algebraic data types - Structs (products) and enums (sums) with generics
- Type aliases - Named type synonyms with generic support
- Pattern matching - Exhaustive matching with destructuring everywhere
- First-class functions - Lambdas, closures, and higher-order functions
- Impl blocks - Methods and associated functions on user-defined types
- Module system - Organize code into modules with public/private visibility
- Standard library -
Option<T>,Result<T, E>,Dict<K, V>,Set<T>, JSON, HTTP, and more - String interpolation -
$"hello {name}!"syntax with embedded expressions - HTTP functions - Annotate functions with
#[get("/path")]to define HTTP handlers - Immutable by default - All data structures are persistent and immutable
- Compiles to JavaScript - Run anywhere JS runs
Requires Rust (1.85+).
git clone https://github.com/tchak/zoya
cd zoya
cargo install --path crates/zoyaZoya is organized as a Cargo workspace with multiple crates:
| Crate | Description |
|---|---|
| zoya | Main compiler and CLI |
| zoya-ast | Abstract Syntax Tree types |
| zoya-build | Build orchestration (load + check + codegen) |
| zoya-check | Type checker with Hindley-Milner inference |
| zoya-codegen | JavaScript code generation |
| zoya-dashboard | Dev dashboard (embedded React SPA) |
| zoya-fmt | Source code formatter |
| zoya-ir | Typed IR and type definitions |
| zoya-job | Background job processing (SQLite + apalis) |
| zoya-lexer | Tokenizer (logos) |
| zoya-loader | Package file loading |
| zoya-naming | Naming conventions and validation |
| zoya-package | Package data structures |
| zoya-parser | Parser (chumsky) |
| zoya-router | HTTP router (Axum) |
| zoya-run | Runtime execution (QuickJS) |
| zoya-std | Standard library |
| zoya-test | Test runner |
| zoya-value | Runtime value types and serialization |
zoya init my_project
cd my_projectThis creates:
my_project/
├── package.toml # Package configuration
└── src/
└── main.zy # Entry point
The package.toml file defines the package:
[package]
name = "my_project"Optional fields:
[package]
name = "my_project"
main = "src/main.zy" # Entry point (default: src/main.zy)
output = "build" # Build output directory (default: build)Start an interactive session:
zoya repl> let greeting = "Hello, Zoya!"
let greeting: String
> greeting.len()
12
> let add = |x, y| x + y
let add: (?0, ?0) -> ?0
> add(1, 2)
3
zoya run # Run main in current directory
zoya run -p program.zy # Run main from a file
zoya run -p path/to/project # Run main from a project
zoya run --mode test # Run in test mode
zoya run --json # Output result as JSON
zoya run add 1 2 # Run a named function with argumentsValidate types without executing:
zoya check # Check package in current directory
zoya check -p program.zy # Check a single filezoya build # Output to stdout
zoya build -p program.zy # Compile a file
zoya build -p program.zy -o out.js # Output to filezoya fmt # Format all .zy files in current directory
zoya fmt -p program.zy # Format a single file
zoya fmt --check # Check formatting without writingzoya test # Run tests in current package
zoya test -p path/to/project # Run tests at pathStart an HTTP development server with file watching and hot-reload:
zoya dev # Start dev server (default port 3000)
zoya dev --port 8080 # Custom portFunctions annotated with HTTP method attributes become routes:
use std::http::{Response, Body}
#[get("/")]
pub fn index() -> Response {
Response::ok(Option::Some(Body::Text("Hello!")))
}
#[post("/echo")]
pub fn echo(request: Request) -> Response {
Response::ok(request.body)
}
The server automatically rebuilds when .zy files change.
Define and run job functions with #[job]:
zoya job list # List available job functions
zoya job run deploy # Run a job function
zoya job run deploy arg1 # Run with arguments// This is a line comment
fn main() -> Int {
42 // inline comment
}
Zoya has the following built-in types:
| Type | Examples |
|---|---|
Int |
42, 1_000, -5 |
BigInt |
42n, 9_000_000_000n |
Float |
3.14, 0.5 |
Bool |
true, false |
String |
"hello", "line\nbreak" |
List<T> |
[1, 2, 3], [] |
Dict<K, V> |
persistent hash map |
Set<T> |
persistent hash set |
Bytes |
binary data |
(T, U, ...) |
(1, "hello"), (), (42,) |
T -> U |
Int -> Bool, (Int, Int) -> Int |
// Basic function
fn add(x: Int, y: Int) -> Int {
x + y
}
// Single-expression bodies can omit braces
fn square(x: Int) -> Int x * x
// Return type annotation is optional
fn double(x: Int) x * 2
// Generic functions
fn identity<T>(x: T) -> T x
// Pattern destructuring in parameters
fn swap((a, b): (Int, Int)) -> (Int, Int) (b, a)
let x = 42 // Type inferred as Int
let y: Float = 3.14 // Explicit type annotation
let (a, b) = (1, 2) // Tuple destructuring
let Point { x, y } = point // Struct destructuring
let (first, ..) = long_tuple // Rest patterns
let pair @ (a, b) = (1, 2) // As-patterns (bind whole and parts)
let inc = |x| x + 1
let add = |x, y| x + y
let typed = |x: Int| -> Int x * 2
let block = |x| { let y = x * 2; y + 1 }
// Pattern destructuring
let get_x = |Point { x, .. }| x
let sum_pair = |(a, b)| a + b
Embed expressions in strings using $"..." syntax:
let name = "world"
let greeting = $"hello {name}!" // "hello world!"
let x = 42
let msg = $"the answer is {x}" // "the answer is 42"
let calc = $"1 + 2 = {1 + 2}" // "1 + 2 = 3"
Interpolated expressions must be String, Int, Float, or BigInt.
// Arithmetic
1 + 2 // addition
5 - 3 // subtraction
2 * 3 // multiplication
10 / 3 // integer division
10 % 3 // modulo
2 ** 10 // power (1024)
// Comparison
x == y // equality
x != y // inequality
x < y x > y x <= y x >= y
// Logical
a && b // and
a || b // or
!a // not
// String
"hello" ++ " world" // concatenation
struct Point { x: Int, y: Int }
struct Pair<T, U> { first: T, second: U }
let p = Point { x: 1, y: 2 }
let x_coord = p.x
// Shorthand when variable names match fields
let x = 10
let y = 20
let p = Point { x, y }
enum Color { Red, Green, Blue }
enum Option<T> { None, Some(T) }
enum Result<T, E> { Ok(T), Err(E) }
enum Message {
Quit,
Move { x: Int, y: Int },
Write(String),
}
let color = Color::Red
let maybe = Option::Some(42)
let msg = Message::Move { x: 10, y: 20 }
// Turbofish for explicit type arguments
let none = Option::None::<Int>
Define methods and associated functions on types:
struct Point { x: Int, y: Int }
impl Point {
fn new(x: Int, y: Int) -> Point {
Point { x, y }
}
fn distance(self) -> Float {
let squared = self.x * self.x + self.y * self.y;
squared.to_float().sqrt()
}
}
let p = Point::new(3, 4)
p.distance() // 5.0
Generic impl blocks:
impl<T> Option<T> {
fn map<U>(self, f: (T) -> U) -> Option<U> {
match self {
Option::Some(v) => Option::Some(f(v)),
Option::None => Option::None::<U>,
}
}
}
Create named synonyms for types:
type UserId = Int
type Callback = (Int) -> Bool
type Pair<A, B> = (A, B)
type StringList = List<String>
fn get_user(id: UserId) -> String { ... }
fn make_pair() -> Pair<Int, Bool> { (1, true) }
Type aliases are transparent - UserId and Int are interchangeable everywhere.
fn describe(opt: Option<Int>) -> String {
match opt {
Option::None => "nothing",
Option::Some(0) => "zero",
Option::Some(n) => n.to_string(),
}
}
// List patterns
match list {
[] => "empty",
[x] => "single",
[x, y] => "pair",
[first, ..] => "has first",
[.., last] => "has last",
[first, .., last] => "has both",
}
// Tuple patterns
match tuple {
(0, _) => "starts with zero",
(_, 0) => "ends with zero",
(a, b) => a + b,
}
Pattern matching is exhaustive - the compiler ensures all cases are covered.
Spread lists into other lists:
let xs = [1, 2, 3]
let ys = [0, ..xs, 4] // [0, 1, 2, 3, 4]
Zoya organizes code into modules using mod declarations. Each module maps to a file:
// src/main.zy
mod utils // loads src/utils.zy
mod math // loads src/math.zy
fn main() -> Int {
utils::helper()
}
// src/utils.zy
pub fn helper() -> Int { 42 }
For nested modules:
// src/math.zy
mod geometry // loads src/math/geometry.zy
pub fn add(x: Int, y: Int) -> Int x + y
// src/math/geometry.zy
pub fn area(w: Int, h: Int) -> Int w * h
Module names must be snake_case.
Items are private by default. Use pub to make them accessible from other modules:
pub fn public_function() -> Int { 42 }
fn private_function() -> Int { 10 }
pub struct Point { x: Int, y: Int }
struct Internal { data: Int }
pub enum Color { Red, Green, Blue }
pub type UserId = Int
pub mod submodule
Public items can reference only public types in their signatures:
pub struct Pair { x: Int, y: Int } // OK: Int is always visible
pub fn make_pair() -> Pair { ... } // OK: Pair is pub
// pub fn get_internal() -> Internal { ... } Error: Internal is private
Use use to bring items from other modules into scope:
// Import a specific item
use root::utils::helper
// Use it without qualification
fn main() -> Int {
helper()
}
Import a module as a namespace:
use root::math
fn main() -> Int {
math::add(1, 2) // access items through the module name
}
Glob imports bring all public items from a module, including child modules:
use root::types::* // imports all public items and modules from types
fn main() -> Int {
let c = Color::Red; // Color was imported via glob
helper() // helper was imported via glob
child_mod::something() // public child modules are also imported
}
Glob and group imports can also target enums to import variants directly:
use root::types::Color::* // import all variants
use root::types::Option::{Some, None} // import specific variants
fn main() -> Int {
match Some(Red) {
Some(Red) => 1,
_ => 0,
}
}
Group imports bring specific items:
use root::math::{add, subtract}
fn main() -> Int {
add(1, subtract(5, 3))
}
Path prefixes for navigation:
| Prefix | Meaning |
|---|---|
root:: |
Absolute path from the package root |
self:: |
Relative to the current module |
super:: |
Relative to the parent module |
use root::math::add // absolute import
use self::helpers::format // relative import
use super::shared::Config // parent module import
Use pub use to re-export imported items. All import forms support pub:
pub use root::math::add // re-export single item
pub use root::math // re-export a module
pub use root::collections::* // re-export all public items and modules
pub use root::math::{add, subtract} // re-export specific items
pub use root::types::Color::* // re-export all enum variants
pub use root::types::Color::{Red} // re-export specific variants
This makes the items available to anyone who can access the current module, even though they are defined elsewhere.
Zoya includes a standard library with common types and methods:
// Option<T> - represents an optional value
let some_val = Option::Some(42)
let no_val = Option::None::<Int>
some_val.map(|x| x + 1) // Option::Some(43)
some_val.unwrap() // 42
// Result<T, E> - represents success or failure
let ok = Result::Ok::<Int, String>(42)
let err = Result::Err::<Int, String>("oops")
ok.map(|x| x + 1) // Result::Ok(43)
// Set<T> - persistent hash set
let s = Set::new::<Int>()
let s = s.insert(1).insert(2)
s.contains(1) // true
s.len() // 2
// HTTP types (for HTTP handler functions)
use std::http::{Request, Response, Body, Method, Headers}
Methods on primitive types (defined in the standard library via impl blocks):
// String
"hello".len() // 5
"hello".is_empty() // false
"hello".contains("ell") // true
"hello".to_uppercase() // "HELLO"
"hello world".split(" ") // ["hello", "world"]
" hello ".trim() // "hello"
// Int
(-5).abs() // 5
42.to_string() // "42"
3.min(5) // 3
2.pow(10) // 1024
// Float
3.14.floor() // 3.0
3.14.ceil() // 4.0
4.0.sqrt() // 2.0
1.5.round() // 2.0
// BigInt
42n.abs() // 42n
42n.to_string() // "42"
// List (all operations return new lists)
[1, 2].push(3) // [1, 2, 3]
[1, 2].concat([3, 4]) // [1, 2, 3, 4]
[1, 2, 3].reverse() // [3, 2, 1]
[1, 2, 3].map(|x| x * 2) // [2, 4, 6]
[1, 2, 3].filter(|x| x > 1) // [2, 3]
// Dict
Dict::new::<String, Int>() // empty dict
dict.insert("key", 42) // new dict with entry
dict.get("key") // Option::Some(42)
dict.keys() // list of keys
// Set (all operations return new sets)
Set::new::<Int>() // empty set
Set::from([1, 2, 3]) // set from list
set.insert(4) // new set with element
set.contains(1) // true
set.union(other) // set union
set.intersection(other) // set intersection
// Option
Option::Some(42).map(|x| x + 1) // Option::Some(43)
Option::Some(42).unwrap() // 42
Option::None::<Int>.unwrap_or(0) // 0
Option::Some(42).is_some() // true
// Result
Result::Ok::<Int, String>(42).map(|x| x + 1) // Result::Ok(43)
Result::Ok::<Int, String>(42).unwrap() // 42
Result::Err::<Int, String>("no").unwrap_or(0) // 0
Zoya enforces naming conventions at compile time:
- PascalCase: struct names, enum names, variant names, type parameters
- snake_case: function names, variable names, parameters, module names
struct MyStruct { } // OK
struct myStruct { } // Error!
fn my_function() { } // OK
fn myFunction() { } // Error!
mod my_module // OK
mod MyModule // Error!
See ROADMAP.md for planned features.
Contributions welcome! The compiler is written in Rust. See CLAUDE.md for development guidelines.
cargo test # Run tests
cargo clippy # Lint