Rust · statically-typed language
Esperanto is a small statically-typed language with type inference, built from scratch in Rust. The part most build-your-own-language projects skip is the centrepiece here: a program that does not type-check never runs. Try it, right in your browser.
This runs a faithful JavaScript port of the real engine, the same lexer, parser, type checker, and interpreter as the Rust crate. The status line type-checks as you type. Pick a broken example to watch the checker reject it before it runs.
Write Esperanto in the editor, or load an example with the chips. The status line above the code type-checks continuously as you type, showing the inferred type or the exact error. Press Run to evaluate a well-typed program. Try one of the broken examples to see the checker reject it with a precise message before any code executes. Everything runs in your browser.
the same lexer, parser, type checker, and interpreter as the Rust crate, running client-side
The CLI runs a program, type-checks one without running, and reports the exact error when a program is ill-typed. Real output from the crate.
The primitives that matter
Six rules, all in one file (src/types.rs, 248 lines). Nothing here is guessed at runtime; every one is decided before a single expression executes.
Arithmetic is Int -> Int, comparison is Int -> Bool, ++ is Str -> Str, and && || ! are Bool. Each operator has a fixed type.
A binding takes the inferred type of its value. An optional annotation is checked against it, so ordinary values carry no types at all.
Parameters are annotated; the return type is optional and inferred from the body. A function's type is (P1, ..., Pn) -> R.
The condition must be Bool and both branches must share a type, which becomes the type of the whole expression.
The one place an annotation is mandatory. let rec needs a declared return type so the name is in scope while its body is checked, and the checker says so rather than guessing.
== and != compare two values of the same non-function type. Comparing functions is rejected outright.
Each of these is rejected by the checker with the exact message Esperanto prints. Paste any of them into the playground above.
# each line below is rejected at type-check time, before running true + 1; # arithmetic operator: expected Int, found Bool if true then 1 else "x"; # if branches disagree: then is Int, else is Str let f = fn(n: Int) => n; f(true); # argument 1: expected Int, found Bool let x = 5; x(3); # cannot call a value of type Int
Most build-your-own-language projects are dynamically typed, because the type checker is the part tutorials skip. Esperanto keeps that part and makes it the readable centrepiece. These are the honest trade-offs, straight from the design notes.
Type-checking is a separate, total pass. Stop after it with --check and an ill-typed program is reported without ever running. A program that does not type-check never runs.
There is no unification variable and no let-generalization. The trade is honesty about scope: you can hold the entire checker in your head, and it still infers the common cases and rejects the real errors.
A recursive binding must be a function with a declared return type, so the name is in scope while its body is checked. The checker says this outright rather than guessing.
No modules, no mutation, no loops (recursion is the iteration construct), no user-defined types or generics. Each would blur the read-it-in-one-sitting line. The point is the checked core, not the size of the language.
No bytecode, no VM, no parser generator. Lexer, parser, checker, and interpreter are a few hundred lines each: 190, 304, 248, and 125 lines of Rust.
Types, inference, functions, closures, recursion, and strings.
# comments start with '#'. statements end with ';'. let answer = 6 * 7; # Int, inferred (no annotation) let greeting: Str = "hello"; # optional annotation, checked # parameters are annotated; the return type is optional let add = fn(a: Int, b: Int) => a + b; # a recursive binding declares its return type so it is in scope while checked let rec factorial = fn(n: Int) -> Int => if n <= 1 then 1 else n * factorial(n - 1); # closures capture their scope; curried adder let add5 = (fn(x: Int) -> (Int) -> Int => fn(y: Int) -> Int => x + y)(5); print(factorial(10)); # 3628800 print(greeting ++ ", " ++ str(add5(add(1, 1)))); # hello, 7
Type-checking is a distinct stage. Stop after it, and an ill-typed program is reported without ever running.
One crate, no dependencies. Run a file, type-check one, drop into the REPL, or call the library directly.
esperanto examples/factorial.esp lexes, parses, type-checks, then evaluates and prints.
esperanto --check file.esp stops after the checker. It prints ok or the exact type error, and nothing runs.
Run esperanto with no arguments. Each line is a full program (statements end with ;); ctrl-d to quit.
The crate exposes run(src) and typecheck(src) from lib.rs, returning a Result so callers see the same errors.
cargo test runs 20 tests covering evaluation, inference, and the rejections the checker should make.
# clone, then run any of these git clone https://github.com/pavanchow/esperanto cd esperanto cargo run -- examples/factorial.esp # run a program cargo run -- --check examples/fib.esp # type-check without running cargo run # REPL cargo test # 20 tests