Using the compiler
Take one program through essence check, build, run and watch, read what each prints, and wire the commands into CI.
essence is one command for the whole toolchain: compiler, formatter, test runner, language server and debug adapter. check type-checks a program, build writes JavaScript, run compiles and runs it, and watch rebuilds on every save. This guide takes one program through all four, shows how to read what each prints, and then wires them into CI.
The commands are written as plain essence, which a global install puts on your path. With a project install, put your package manager’s prefix in front: npx, pnpm exec, yarn or bun run. Installation covers both.
Put this program in a file called greeting.es:
implementation {
§ A greeting, with the name interpolated into the String.
constant name = "world"
Terminal.print("Hello, {name}!") § Hello, world!
}
Terminal.print writes a line of output, and {name} is a hole that puts the value of name into the string. A § starts a comment. These docs end a line with one to show what that line prints.
Check it
essence check reads the program, reports what is wrong with it, and writes nothing:
$ essence check greeting.es
✔ greeting.es no errors
read 0.11 ms ░
parse 1.1 ms █
enrich 19 ms ████████████████████████
validate 0.46 ms █
total 22 msThe first line says the file has no errors. Each row below it is a stage of the compiler, with the time it took. Timings vary from run to run. A check runs four stages, in this order:
| Stage | What it does |
|---|---|
read |
Reads the source file. |
parse |
Turns the text into a syntax tree, and refuses text that is not Essence. |
enrich |
Resolves every name and infers the type of every expression. |
validate |
Checks the typed program against the language’s rules, such as whether a value fits its declared type. |
Now give name a type it does not have. Change line 3 to constant name: Integer = "world" and check again:
[assignment-type-mismatch]
Error: This value does not fit the declared Type of Constant 'name'
╭─┤ greeting.es:3:27 │
│
3 │ constant name: Integer = "world"
│ ───┬───
│ ╰───── this is a String
│
│ Note: 'name' is declared as Integer.
───╯
─── 1 error
✖ greeting.es 1 errorEvery stage ran, so the report is the whole of what is wrong with the file. essence check exits with code 1.
assignment-type-mismatch, in brackets on the first line, is the error’s code. The code stays the same from release to release, and the message text can change. A script, an editor or a search can rely on the code, and the Diagnostics reference explains it under that name.
Every stage runs, whatever the one before it found, and one report holds all of it. A syntax error, a call to a method that does not exist and a function that forgets to return come out together, in the order the lines are written — one run to find them rather than one run each.
The stages recover rather than stopping: parse drops the statement it could not read and carries on with the rest of the file, and enrich types what is left and marks what it could not. What a later stage says about a hole an earlier one left, it does not say at all — a name whose declaration was dropped is not reported as undeclared, and a function missing the <- that went with it is not reported as falling off the end. One mistake gets one report.
That silence reaches exactly as far as the dropped declaration would have: the block it was written in, from its own line down. A total dropped out of one function’s body says nothing about a total read in the next function along — that read is answered like any other name nothing declares, because the declaration was never in scope there to begin with. The exception is a declaration that is in scope across the whole file wherever it stands: a top-level function, type, choice, namespace or protocol. Dropping one of those buys silence file-wide, since every read of the name really was a read of what the file declares.
Warnings never stop anything: a file with only Warnings checks with exit code 0, and it builds and runs.
check covers more than the file you name:
- Every module the file imports is checked with it. An error in an imported module is reported under that module’s name.
- The
tests { … }block is checked too. A build leaves that block out, so a type error inside a test failsessence checkwhileessence buildof the same file succeeds.
Put line 3 back to constant name = "world" before the next step.
Build it
essence build runs the same four stages and five more, and writes the JavaScript:
$ essence build greeting.es
✔ greeting.es → greeting.js
read 0.09 ms ░
parse 1.2 ms ██
enrich 19 ms ███████████████████████
validate 0.85 ms █
simplify 0.21 ms ░
optimise 1.1 ms █
generate 19 ms ████████████████████████
bundle 14 ms █████████████████
write 2.7 ms ███
output 2.3 kB · 873 B gzipped
total 62 msbuild is the default command, so essence greeting.es does the same thing. The five new rows are the stages that turn the checked program into a file. The output row gives the size of the file, as written and gzipped.
greeting.js is one self-contained ES module. It carries the parts of Essence’s runtime library that the program uses, and it imports nothing. node greeting.js and bun greeting.js both print Hello, world!, with no package installed beside it and no "type": "module" in a package.json. Each file you name on the command line is an entry. Every module an entry imports goes into its file, so each entry becomes exactly one bundle.
Build it a second time without changing it, and the rows from simplify to bundle are gone. The CLI keeps each bundle it emits in a cache, so the second build checks the unchanged source and writes the cached bundle. Step 3 says where the cache lives.
-o says where the output goes. Without it, each output lands beside its source. With one entry, -o names a file, unless the path ends in / or is a directory that already exists. With several, it names a directory, and each output keeps its source’s name. Missing parent directories are created:
essence build greeting.es -o dist/hello.js
essence build src/*.es -o dist/The second command makes every .es file directly in src/ an entry. A module named there as well as imported gets a bundle of its own.
A few flags change what gets written:
--sourcemapwrites a source map,greeting.js.map, beside the output.--minifyminifies the JavaScript.greeting.jsdrops from 2.3 kB to 976 B.--no-sourcemap,--no-minifyand--no-embedturn a setting off for one run when the project file,essence.json, turns it on. Step 5 has the rule.--embedbuilds a bundle that a JavaScript program can load and call, withgreeting.descriptor.jsonbeside it, as Calling Essence from JavaScript explains.--testscompiles thetests { … }block into the bundle, which exports it as$tests. Loading the bundle runs none of the tests. The flag exists for the debugger. To run tests, useessence test.--no-optimiseturns every optimisation pass off, and--without-optimisation <pass>turns one off by name. Optimisations lists the passes. An unknown name is refused: a near miss gets a suggestion, and any other name gets the same list.-j <count>sets how many files compile in parallel, in worker threads. The default is capped at four.-j 1compiles everything on the main thread, which makes the stack trace of a compiler crash easier to read.
Run it
essence run compiles one file and runs it:
$ essence run greeting.es
✔ greeting.es → ~/Library/Caches/essence/cli/11f7408d…9fd67a20b9.mjs
read 0.08 ms ░
parse 1.1 ms █
enrich 18 ms ████████████████████████
validate 0.85 ms █
output 2.3 kB · 873 B gzipped
total 21 ms
─── program output ───────────────────────────────────────────────────────────
Hello, world!
──────────────────────────────────────────────────────────────────────────────
✔ program exited with code 0 after 13 msEverything between the two horizontal lines is the program’s own output. The last line is its exit code, and essence run exits with that same code. A program that crashes, such as one that overflows the stack, ends with ✖ program exited with code 1, and run exits with 1. A program with an Error never starts: run prints the same report as check and exits with 1.
The report stops at validate because step 2 already emitted this bundle, and run reuses it from the cache. Without -o, run writes nothing beside your source. Its bundle goes into the CLI’s cache, named by a hash: ~/Library/Caches/essence/cli/<sha256>.mjs on macOS. On Linux the cache is ~/.cache/essence/cli/, and XDG_CACHE_HOME moves it on either system. The report prints the full path, shortened above. The file stays after the program exits, and the next run of an unchanged file reuses it.
ESSENCE_CLI_CACHE=off turns that cache off, so every build and run compiles from the start. ESSENCE_CLI_CACHE=<directory> moves it to that directory.
To keep the compiled file, give run an -o path, as in essence run greeting.es -o hello.js. A project whose essence.json sets build.out gets the same: run writes the file there instead of into the cache.
run takes exactly one file. Arguments after a bare --, as in essence run greeting.es -- --port 8080, are passed to the program’s process. Nothing in the standard library reads them. Under --json the program still runs, and its output goes to stderr so that the JSON report is alone on stdout.
Watch it
essence watch follows every module a source imports, so give greeting.es a module to import. Move the name into a file of its own, Settings.es. Each block below starts with a comment that names its file. Save the lines after that comment under that name:
implementation {
constant name = "world"
}
export {
name
}import {
from "./Settings.es" { name }
}
implementation {
Terminal.print("Hello, {name}!") § Hello, world!
}The export block makes name visible to other modules, and the import block brings it into greeting.es. Now start watching:
$ essence watch greeting.es --execute
essence watch · greeting.es
✔ greeting.es → greeting.js
read 0.10 ms ░
parse 1.6 ms ██
enrich 20 ms ████████████████████████
validate 0.39 ms ░
simplify 0.22 ms ░
optimise 1.1 ms █
generate 19 ms ███████████████████████
bundle 14 ms █████████████████
write 2.8 ms ███
output 2.4 kB · 879 B gzipped
total 65 ms
· watching for changes r rebuild · c clear · q quit
Hello, world!watch compiles the source, runs it because of --execute, and stays running. Change "world" to "Essence" in Settings.es and save it:
✔ 00:50:18 greeting.es → greeting.js 2.4 kB 15 ms
· watching for changes r rebuild · c clear · q quit
Hello, Essence!Saving Settings.es rebuilt greeting.es, the source that imports it, and ran it again. --execute, or -e, runs the output after every successful rebuild. --clear clears the screen before each rebuild.
Now save a mistake. Change the line in Settings.es to constant name: Integer = "Essence". watch prints the same report check would — the mistake under Settings.es, and greeting.es saying that a module it imports did not compile — and ends with:
─── 2 errors
✖ 00:50:20 greeting.es 2 errors
· watching for changes r rebuild · c clear · q quitNothing runs, and greeting.js keeps the last build that compiled. Change the line back and save, and watch rebuilds and runs the program again.
While watch runs, r forces a rebuild, c clears the screen, and q or Ctrl+C quits. essence build greeting.es with --watch starts the same session. watch also takes the output flags of build, such as -o and --sourcemap.
Wire it into CI
A CI job for a project with its sources under src/ needs three commands:
essence format --check "src/**/*.es"
essence check "src/**/*.es" --quiet
essence testKeep the quotes. With them, essence expands the glob itself, and ** reaches every directory under src/. Without them, the shell expands the glob before essence sees it, and bash then skips the files directly in src/. check does not take a directory: essence check src is refused with a not-a-file error.
essence format --check is prettier --check: it changes nothing, prints the name of each file that is not formatted, and exits with 1 if there is one. Formatting covers the formatter. --quiet makes check print its Errors and Warnings without the stage table or progress. A clean run prints one line, such as ✔ 3 files checked. essence test finds the tests in the project and runs them, as Running tests explains.
Every command reports through its exit code:
| Exit code | Meaning |
|---|---|
0 |
Success. A run with Warnings and no Errors exits 0. |
1 |
The command ran and failed: an Error, an unformatted file under format --check, or a failing test. |
2 |
The command line could not be read: an unknown command or option, contradictory flags, a glob that matches no file (format exits 1 for that), or no file at all. |
essence run and essence build with --execute exit with the program’s own code instead. essence test also exits 2 when a focused test is left in a run that no --filter, --tag or --skip-tag narrows. focused works like Vitest’s test.only: only the focused tests run.
A tool that reads the report wants --json. Save the mistake from step 4 in Settings.es again, constant name: Integer = "Essence", and check greeting.es:
$ essence check greeting.es --json
{
"version": "0.5.0",
"command": "check",
"ok": false,
"errors": 2,
"warnings": 0,
"durationMs": 34.816,
"files": [
{
"input": "greeting.es",
"output": null,
"ok": false,
"bytes": null,
"gzipBytes": null,
"durationMs": 34.367,
"stages": {
"read": 0.103,
"parse": 1.784,
"enrich": 31.627,
"validate": 0.653
},
"diagnostics": [
{
"file": "Settings.es",
"severity": "error",
"message": "This value does not fit the declared Type of Constant 'name'",
"code": "assignment-type-mismatch",
"line": 2,
"column": 27,
"endLine": 2,
"endColumn": 36
},
{
"file": "greeting.es",
"severity": "error",
"message": "./Settings.es has errors of its own",
"code": "dependency-has-errors",
"line": 2,
"column": 7,
"endLine": 2,
"endColumn": 22
}
]
}
]
}The JSON document is the only thing on stdout. Each entry carries its stage timings and a diagnostics array. Every item in that array has a code, a severity, a message and a span, which is what a bot that annotates a pull request needs. An item’s file is the file the problem is in, so the first is Settings.es while input is greeting.es. The second is greeting.es answering for itself: a module whose dependency did not compile did not compile either, and dependency-has-errors is where it says so.
There is no field naming a stage that refused the file, because none of them refuses: every stage runs and every stage reports. stages holds the time each one took.
Colour follows stderr. It is on when stderr is a terminal and off when stderr is piped. Redirecting only stdout to a file keeps it on. NO_COLOR turns colour off and FORCE_COLOR turns it on. --color forces it on, even over NO_COLOR, for a CI log that renders colour.
An essence.json at a project’s root plays the part of tsconfig.json. It is optional. It writes build settings down once. build, run and watch read them, and test reads every one but build.out. A flag wins over the setting it mirrors: --no-sourcemap drops a "sourcemap": true for one run. Such a flag given together with its own negation, as --minify with --no-minify, is refused before anything runs, with exit code 2:
✖ --minify and --no-minify contradict each other — say one.
Usage: essence build <file...> [options]
Help: essence help buildOn build, run, watch, test and init, --verbose starts the report with the project file that applies: settings read from essence.json, or no essence.json governs the working directory. In a CI log, that line shows whether the job picked up an essence.json. essence init writes a file to start from.
Compared with the TypeScript toolchain
These are the nearest TypeScript commands to check, build, run and watch:
tsc --noEmit
esbuild greeting.ts --bundle --format=esm --outfile=greeting.js
tsx greeting.ts
tsx watch greeting.ts
essence checkistsc --noEmit: it type-checks and writes nothing.tsccan take its file list fromtsconfig.json.essence checkhas no such list, so you name the files, and a bareessence checkexits 2.essence buildis closer toesbuild --bundlethan totsc. Each entry becomes one bundled ES module with its imports and the runtime library inside, wheretscwrites one.jsfile per.tsfile.essence runworks liketsx, except that it type-checks first.tsxstrips the types and runs the program.essence runrefuses a program with an Error.essence watch -eistsx watch: both follow the imports and run the program again after a save.
The tooling comparison maps the rest of the JavaScript toolchain.