Esperanto logo

Rust · statically-typed language

A typed language
you can read whole.

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.

View on GitHub Try it live

Live playground

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.

How to use this playground

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.

 
runs entirely in your browser, nothing is sent anywhere
Press Run, or edit the code and watch the type checker above.

the same lexer, parser, type checker, and interpreter as the Rust crate, running client-side

Run it, check it, or reject it

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.

# run a program $ cargo run -- examples/factorial.esp 3628800 # type-check without running $ cargo run -- --check examples/fibonacci.esp ok: examples/fibonacci.esp type-checks # bad.esp contains: true + 1; $ cargo run -- bad.esp type error: arithmetic operator: expected Int, found Bool # the whole suite $ cargo test test result: ok. 20 passed; 0 failed; 0 ignored

The primitives that matter

The type rules the checker enforces

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.

Literals and operators fixed

Arithmetic is Int -> Int, comparison is Int -> Bool, ++ is Str -> Str, and && || ! are Bool. Each operator has a fixed type.

let inference inferred

A binding takes the inferred type of its value. An optional annotation is checked against it, so ordinary values carry no types at all.

Functions annotated

Parameters are annotated; the return type is optional and inferred from the body. A function's type is (P1, ..., Pn) -> R.

if branches must agree

The condition must be Bool and both branches must share a type, which becomes the type of the whole expression.

Recursion explicit

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.

Equality same type

== and != compare two values of the same non-function type. Comparing functions is rejected outright.

Errors, caught before it runs

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

The choices behind it

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.

Checked, then run

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.

Not full Hindley-Milner

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.

Recursion is explicit

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.

Small on purpose

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 dependencies

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.

Language tour

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

How it works

Type-checking is a distinct stage. Stop after it, and an ill-typed program is reported without ever running.

source tokens AST well-typedAST value lexparsecheckeval

Use it

One crate, no dependencies. Run a file, type-check one, drop into the REPL, or call the library directly.

Run a file

esperanto examples/factorial.esp lexes, parses, type-checks, then evaluates and prints.

Type-check only

esperanto --check file.esp stops after the checker. It prints ok or the exact type error, and nothing runs.

REPL

Run esperanto with no arguments. Each line is a full program (statements end with ;); ctrl-d to quit.

Library

The crate exposes run(src) and typecheck(src) from lib.rs, returning a Result so callers see the same errors.

Tests

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