Emela is a functional language with typed errors and typed effects, compiling to JavaScript and WebAssembly.
This branch is the 0.20 rebuild. The current release is 0.20.0:
the pure subset, typed errors and effects, suspending effects, handlers with
state, fibers, files and an HTTP client compile to JavaScript and run on
Node.js. The 0.11 compiler lives on the legacy
branch.
| Release | Adds |
|---|---|
| 0.20.0-alpha.1 | Functions, records, enums, opaque types, pattern matching, tail calls, pipes, List / Option, modules, @test, dbg |
| 0.20.0-alpha.2 | Typed errors and effects: error, fail, escape, effect, handler, use, with, @external, Console |
| 0.20.0-alpha.3 | suspend fn, Async, Immediate, Clock, Random, Env, Instant / Duration |
| 0.20.0-alpha.4 | Handlers with fields, init and release, layer, State, the Std namespace, Platform |
| 0.20.0-alpha.5 | Fibers (Std.Fiber, Std.Cause, Std.Deferred), FileSystem, Std.Path, the Http client |
| 0.20.0 | Postfix match, const evaluated once on first use, E0208 for a directory outside the source root, English diagnostics |
| 0.21 and later | trait, impl, derive, Map / Set / Array, JSON, Semaphore, Queue, an Http server, WebAssembly output |
The 0.20 language specification is in spec/.
Changes to it are proposed in proposals/. The
emela-lang/specification
repository describes the 0.1x language, not 0.20.
Prebuilt binaries are published for macOS (Apple Silicon) and Linux (x86_64).
Running the generated JavaScript (emela run, emela test) needs Node.js 18
or later. The C target (--target c) needs a C11 compiler as cc (or set
EMELA_CC). The WASM target (--target wasm) needs WebAssembly run as a WASI
command with wasmtime (or set EMELA_WASMTIME).
curl -fsSL https://emela.dev/install.sh | sh
emela --versionThis installs the latest release into $HOME/.emela/bin; set
EMELA_INSTALL_DIR to change the directory, or EMELA_VERSION (for example
v0.20.0-alpha.5) to pick a version.
A project is a directory with a Pome.toml (empty is fine) and its sources under
src/, with the entry point in src/main.emel. Each module is one file:
src/email.emel is imported as Email. This is
examples/tour:
tour/
├── Pome.toml
└── src/
├── main.emel
└── email.emel
src/main.emel:
# A tour of the pure part of the language. `dbg` prints to stderr.
import Email
# Variants carry labelled fields.
enum Shape {
Circle(radius: Float)
Rect(width: Float, height: Float)
}
# A record type.
type Todo(id: Int, title: String, done: Bool)
fn area(shape: Shape) -> Float {
shape match {
Circle(radius:) -> 3.14 * radius * radius
Rect(width:, height:) -> width * height
}
}
# Self tail calls compile to loops, so deep recursion is fine.
fn sum(xs: List[Int], acc: Int) -> Int {
xs match {
[] -> acc
[x, ..rest] -> sum(rest, acc + x)
}
}
fn fizzbuzz(n: Int) -> String {
(n % 3, n % 5) match {
(0, 0) -> "FizzBuzz"
(0, _) -> "Fizz"
(_, 0) -> "Buzz"
_ -> Int.to_string(n)
}
}
fn main() {
todo = Todo(id: 1, title: "Write docs", done: False)
finished = Todo(done: True, ..todo)
dbg(finished) # Todo(id: 1, title: "Write docs", done: True)
dbg(area(Rect(width: 2.0, height: 3.0))) # 6.0
dbg(sum(List.range(1, 10001), 0)) # 50005000
# "1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz"
List.range(1, 16)
|> List.map(fizzbuzz)
|> String.join(" ")
|> dbg
dbg(Email.parse("alice@example.com") |> Option.map(Email.to_string)) # Some("alice@example.com")
dbg(Email.parse("not an email")) # None
}
# Run with `emela test`.
@test
fn fizzbuzz_of_15() {
assert fizzbuzz(15) == "FizzBuzz"
}
src/email.emel:
# `opaque` hides the constructor and fields outside this module,
# so every Email goes through `parse`.
pub opaque type Email(value: String)
pub fn parse(input: String) -> Option[Email] {
if String.contains(input, "@") { Some(Email(value: input)) } else { None }
}
pub fn to_string(email: Email) -> String {
email.value
}
$ emela run
Todo(id: 1, title: "Write docs", done: True)
6.0
50005000
"1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz"
Some("alice@example.com")
None
$ emela test
running 1 test
test fizzbuzz_of_15 ... ok
test result: ok. 1 passed; 0 failed; 0 filtered outEach command takes a project directory or an entry file, and defaults to ..
run, build and test take --target js (the default), --target c, which
compiles to C and builds a native executable with cc, or --target wasm, which
writes a WASI main.wasm (target/emela/wasm) and runs it with wasmtime;
[build] target = "..." in Pome.toml changes the default. The C target does
not run suspending code, fibers or String.length yet; the WASM target does not
run those, Float operations or host callbacks either.
| Command | Does |
|---|---|
emela new |
Creates a new Pome in a new directory; --lib creates a library |
emela check |
Reports diagnostics without compiling |
emela run |
Compiles and runs: JavaScript on Node.js, a native executable, or WASM with wasmtime |
emela build |
Compiles under target/emela/js (JS), a native executable (C), or main.wasm (WASM) |
emela test |
Runs the functions marked @test |
emela fmt |
Formats the sources in place; --check only lists them |
emela lsp |
Starts the language server over stdio |
The language server reports diagnostics and offers hover (types, fails, use),
inlay hints, completion, go to definition, references, rename, signature help,
formatting, and a code action that writes inferred annotations.
Programs print with dbg, which writes to stderr. The value main returns is
not printed.
Side effects go through effects. The bundled Console effect writes lines, and
SystemConsole provides it on the host. Both live in Std.Console and are
imported. This is examples/hello:
import Std.Console.{Console, SystemConsole}
fn greet(name: String) {
console = use Console
console.print_line("Hello, #{name}!")
}
fn main() {
with SystemConsole {
console = use Console
console.print_line("hi")
List.map(["Emela", "world"], greet)
console.print_error_line("done")
}
}
$ emela run
hi
Hello, Emela!
Hello, world!
doneErrors are values declared with error. A function's errors (fails) and
effects (use) are part of its type and inferred unless written. escape
handles some errors and lets the rest propagate; an error that reaches main
exits with status 1. This is examples/errors:
# Typed errors and effects. `fails` and `use` are inferred unless written.
import Std.Console.{Console, SystemConsole}
error NotFound(id: Int)
error Invalid(reason: String)
type User(id: Int, name: String)
# An effect declares operations; handlers implement them.
effect Users {
fn find(id: Int) -> User fails NotFound
}
handler SampleUsers: Users {
fn find(id) {
id match {
1 -> User(id: 1, name: "alice")
2 -> User(id: 2, name: "bob")
_ -> fail NotFound(id:)
}
}
}
# Inferred: fails NotFound | Invalid, use Users.
fn greeting(id: Int) {
if id < 0 { fail Invalid(reason: "negative id") }
user = (use Users).find(id)
"Hello, #{user.name}!"
}
# `escape` handles NotFound; Invalid still propagates.
fn greeting_or_guest(id: Int) {
greeting(id) escape {
NotFound(_) -> "Hello, guest!"
}
}
fn main() {
with SystemConsole, SampleUsers {
console = use Console
console.print_line(greeting_or_guest(1))
console.print_line(greeting_or_guest(3))
console.print_line(greeting_or_guest(-1))
}
}
# Tests swap the handler instead of mocking.
handler NoUsers: Users {
fn find(id) { fail NotFound(id:) }
}
@test
fn unknown_users_get_the_guest_greeting() {
with NoUsers {
assert greeting_or_guest(1) == "Hello, guest!"
}
}
$ emela run
Hello, alice!
Hello, guest!
error: Invalid(reason: "negative id")
$ emela test
running 1 test
test unknown_users_get_the_guest_greeting ... ok
test result: ok. 1 passed; 0 failed; 0 filtered outA suspend fn operation may pause its caller; the handler decides when to
resume. Callers write ordinary calls, so the same countdown runs on a real
clock in main and on an instant one in the test. This is
examples/clock:
# Suspending effects. `sleep` is a `suspend fn`: calls to it look like any other
# call, and the handler decides when the caller resumes.
import Std.Console.{Console, SystemConsole}
import Std.Clock.{Clock, SystemClock}
import Std.Instant.{Instant}
fn countdown(n: Int) {
console = use Console
clock = use Clock
List.map(List.range(0, n), fn(i) {
clock.sleep(100)
console.print_line("#{n - i}")
})
console.print_line("liftoff")
}
fn main() {
with SystemConsole, SystemClock {
countdown(3)
}
}
# Resumes on the spot, so tests do not wait and need no `Async`.
handler InstantClock: Clock {
suspend fn sleep(ms, resume) { resume(()) }
fn now() { Instant.from_unix_milliseconds(0) }
}
@test
fn countdown_does_not_wait() {
with SystemConsole, InstantClock {
countdown(3)
}
}
$ emela run
3
2
1
liftoffThe bundled std lives under Std and is imported: the platform effects
Std.Console, Std.Clock, Std.Random, Std.Env, Std.FileSystem and
Std.Http, each with its host handler (SystemConsole, SystemClock,
SystemRandom, SystemEnv, SystemFileSystem, SystemHttp), the layer
Std.Platform.Platform that bundles them, Std.State, Std.Fiber,
Std.Cause, Std.Deferred, Std.Path, Std.Instant and Std.Duration. The
test handlers TestConsole, TestClock, TestFileSystem and TestHttp keep
their state in State. The core modules (List, Option, String, Int, Int64,
Float) need no import.
Handlers can hold state: init runs when a with is entered and builds the
handler's value, and release runs when the with is left, however its body
ends. A layer names a bundle of handlers, initialized in the order their
dependencies need, and a handler listed later replaces the layer's handler for
the same effect. This is examples/resources:
$ emela run
open store v1
v1/alice
v1/bob
close store v1Mutable state is the State effect: State.run(initial, body) returns the
body's value and the final state, so a test can read what TestConsole
recorded.
(_, lines) = State.run([], fn() {
with TestConsole {
greet("a")
greet("b")
}
})
# lines == ["hello, a", "hello, b"]
Fibers run concurrently on one thread and switch only where a call may
suspend. They start only inside Fiber.scope, and every fiber ends before its
scope does; a failing fiber interrupts its siblings, and the error reaches the
scope. Fiber.both, Fiber.map, Fiber.all, Fiber.race and Fiber.timeout
are built on the scope, so Fiber.scope and Fiber.fork are needed only for
the shapes they do not cover. This is examples/fibers,
where fetch(id, ms) sleeps for ms and prints fetched <id>:
fn main() {
with SystemConsole, SystemClock {
console = use Console
# The waits overlap: the second fetch ends first.
(a, b) = Fiber.both(fn() { fetch(1, 200) }, fn() { fetch(2, 100) })
console.print_line("both: #{a}, #{b}")
names = Fiber.map([3, 4, 5], fn(id) { fetch(id, 100 * (6 - id)) })
console.print_line("map: #{names}")
first = Fiber.race([fn() { fetch(6, 300) }, fn() { fetch(7, 100) }])
console.print_line("race: #{first}")
late = Fiber.timeout(100, fn() { fetch(8, 1000) })
console.print_line("timeout: #{late}")
# A failing child interrupts its siblings, and the error reaches the scope.
failed = Fiber.scope(fn() {
ok = Fiber.fork(fn() { fetch(9, 1000) })
bad = Fiber.fork(fn() { fetch(-1, 100) })
"#{Fiber.join(ok)} #{Fiber.join(bad)}"
}) escape {
NotFound(id:) -> "not found: #{id}"
}
console.print_line(failed)
}
}
$ emela run
fetched 2
fetched 1
both: user 1, user 2
fetched 5
fetched 4
fetched 3
map: ["user 3", "user 4", "user 5"]
fetched 7
race: user 7
timeout: None
fetched -1
not found: -1FileSystem reads and writes text, and each operation fails only with the
errors it lists, so escape handles exactly those. Std.Path builds path
strings. From examples/notes:
## Appends `text` as a line to `notes/<day>.txt` and returns the number of lines in it.
fn note(day: String, text: String) {
fs = use FileSystem
fs.create_directory(DIR)
path = Path.join(DIR, "#{day}.txt")
fs.append_text(path, "#{text}\n")
String.split(fs.read_text(path), "\n") |> List.filter(fn(l) { l != "" }) |> List.length
}
## The text of `notes/<day>.txt`, or a message if there is none.
fn show(day: String) {
(use FileSystem).read_text(Path.join(DIR, "#{day}.txt")) escape {
FileNotFound(path:) -> "no notes in #{path}"
}
}
Http is a client with one operation, send; requests and responses are
values, and a 4xx or 5xx status is a response, not a failure. TestHttp
answers with a function, so tests need no network. From
examples/http:
fn search_request(query: String, page: Int) -> Request {
query_string = Http.encode_query([("q", query), ("page", Int.to_string(page))])
Http.request(Get, "#{BASE}/search?#{query_string}") |> Http.add_header("accept", "text/plain")
}
fn first_result(query: String) {
first_line(query) escape {
UnexpectedStatus(status:, body: _) -> "status #{status}"
ConnectionFailed(url: _, message:) -> "unreachable: #{message}"
}
}
@test
fn a_404_is_not_a_failure() {
server = TestServer(respond: fn(req, n) { Some(Http.response(404, "")) }, sent: [])
(text, after) = State.run(server, fn() { with TestHttp { first_result("x") } })
assert text == "status 404"
assert List.length(after.sent) == 1
}
With with Platform { ... }, the same code sends real requests through
fetch. For a deadline, wrap the call in Fiber.timeout; interrupting a fiber
that waits on send aborts the request.
- No binary data: there is no
Bytes, soFileSystemandHttphandle UTF-8 text only. - No JSON yet; it comes in 0.21 with traits.
- The JavaScript target assumes a server host: Node.js 18 or later, Deno, or Bun. Browsers are not supported in 0.20.
- No limit on how many fibers run at once (
Semaphore), noQueueand noHttpserver yet; they come in 0.21. - Some checks that scopes, fiber handles,
Deferredvalues and handler capabilities do not escape run at run time in 0.20 (a defect such asfiber scope was used after it ended). 0.22 makes them all static.
| Crate | Role |
|---|---|
emela-syntax |
Lexer, parser, lossless syntax tree, error recovery |
emela-fmt |
Formatter |
emela-resolve |
Module loading, imports, name resolution, pub / opaque, naming rules |
emela-types |
Type inference, fails / use inference, traits, exhaustiveness |
emela-core |
Core IR and lowering: derive, tail-call loops, layer wiring |
emela-lir |
Low IR below the Core IR: the Flow IR (structured ANF) and the conversion from monomorphized Core, later the Heap IR |
emela-codegen-js |
JavaScript output and the embedded JS runtime |
emela-codegen-wasm |
WebAssembly output and the embedded WASM runtime |
emela-driver |
Pipeline, diagnostics, Pome.toml |
emela-lsp |
Language server |
emela-cli |
The emela binary |
syntax → resolve → types → core ──────→ codegen-js ───┐
↓ └─→ lir → codegen-wasm ──┤
fmt ↓
driver → lsp → cli
cargo build
cargo test