Running TJS: Files, the CLI and Bundlers
The examples in this book run in the page. A real program lives in files, and this chapter
is about where those files run: on the command line with the tjs CLI, directly under Bun,
as plain .js after a build step, in a browser, and from your own code.
This chapter is for readers who already build JavaScript projects: it assumes a terminal, a package manager, ES modules and a bundler. If you are learning to program with Learn to Program, you can skip it; everything there runs in the page.
The short version: TJS compiles to ordinary JavaScript. Every route below ends with a .js
module that any JavaScript engine can load; the routes differ only in when and where that
compilation happens.
The file extension decides the language
TJS has no mode switches at the top of a file. The extension says which language a file is written in:
| Extension | Treated as | What changes |
|---|---|---|
.tjs |
native TJS | every TJS rule applies (see below) |
.js, .mjs, .cjs |
JavaScript | nothing: JavaScript semantics are preserved exactly |
.ts, .mts, .cts |
TypeScript | converted to TJS first with fromTS (see tjs convert below) |
So the same line can mean two things. This file, saved once as plain.js and once as
plain.tjs:
console.log('5' == 5, typeof null)
prints true object from tjs run plain.js and false null from tjs run plain.tjs. In a
.tjs file == never converts types, so a string is never equal to a number, and
typeof null is 'null' rather than JavaScript's historical 'object'.
JS Footgun Fixes lists the rest, and
TJS for JavaScript Programmers covers the other rules: a parameter's colon
value is an example that sets its type, and a function's return example is a test.
That is what lets you drop existing JavaScript into a TJS project and change it one file at
a time: renaming a file to .tjs is the opt-in.
Installing
bun add -d tjs-lang
bunx tjs --version
The package carries the CLI (tjs), the transpiler, the runtime and the Bun plugins. The CLI
is a Bun program, so you need Bun installed to run it, even in a project
whose output runs on Node.
A dev install does not put tjs on your PATH. Run it as bunx tjs …, or from a
package.json script; the commands below write just tjs.
The example project
The rest of the chapter uses one small project: a module of arithmetic and a program that uses it.
my-project/
package.json
bunfig.toml
src/
mymath.tjs
main.tjs
src/mymath.tjs has no imports, so it runs on this page too:
export function add(a: 0, b: 0): 0 {
return a + b
}
export function multiply(a: 0, b: 0): 0 {
return a * b
}
test 'add and multiply' {
expect(add(2, 3)).toBe(5)
expect(multiply(2, 3)).toBe(6)
}
a: 0 says a is an integer, because the example 0 is one (0.0 would allow any number).
The return example : 0 is tested: TJS calls add(0, 0) and checks the answer is 0.
src/main.tjs imports it, so it can only run beside it. A TJS function that refuses an
argument does not throw: it returns an error value, a MonadicError, which has a .message
(see Error Handling). The last line prints one:
import { add, multiply } from './mymath.tjs'
function calculate(x: 0, y: 0): 0 {
return multiply(add(x, y), 2)
}
console.log('calculate(3, 4) =', calculate(3, 4))
console.log(add(1.5, 2).message)
package.json sets "type": "module". The emitted files are ES modules, and without it Node
still runs them but first warns and reparses each one. Bun does not need it:
{ "name": "my-project", "type": "module" }
bunfig.toml tells Bun how to load .tjs files:
preload = ["tjs-lang/bun-plugin"]
[test]
preload = ["tjs-lang/bun-plugin"]
The first preload covers bun and tjs run; tjs run needs it only when a file imports
other .tjs files. The [test] one covers bun test, which reads only its own section.
The tjs CLI
Six commands. Each takes a file; check, emit, test and convert also take a directory.
tjs check src/ # compile, run examples and tests, report problems
tjs run src/main.tjs # compile and execute
tjs test src/mymath.tjs # run a file's test blocks and signature examples
tjs emit src/ -o dist/ # write .js (and docs, and .d.ts with --dts)
tjs types src/mymath.tjs # print the signatures as JSON
tjs convert src/ -o tjs/ --emit-tjs # TypeScript to TJS
tjs check: run it before you commit or ship
tjs check is the command that tells you whether the code is right: it exits with status 1
when anything is wrong, so it belongs in CI and before a commit. It compiles each file, runs
its signature examples and test blocks, and on success prints the signatures it found:
$ tjs check src/mymath.tjs
✓ src/mymath.tjs
add(a: integer, b: integer) -> integer
multiply(a: integer, b: integer) -> integer
Over a directory it checks every .tjs, .js, .mjs and .cjs file (not .ts), prints one
line per file, and stays quiet about signatures unless you add --verbose.
A signature example is a test. TJS calls each function with its parameter examples and checks
that the result equals the return example. Here add(2, 3) is 5, not 0:
// broken.tjs
function add(a: 2, b: 3): 0 {
return a + b
}
console.log(add(2, 3))
$ tjs check broken.tjs
5
✗ broken.tjs
Transpile-time test failures:
Function signature example is inconsistent:
Expected 0 at 'add', got 5
and it exits with status 1. The 5 at the top is the file's own console.log: running the
tests means running the module, so top-level code executes during a check.
Exit status is 0 when every file compiles and nothing fails, and 1 when a file does not
compile, an example or test fails, or the warnings exceed --max-warnings.
Warnings are advice that does not fail a check by itself. For example, a switch in a .tjs
file gets a warning suggesting given, TJS's replacement for switch (see
the switch case study). --max-warnings N makes more than N warnings
fatal for the whole run, the same shape as ESLint's flag, so a project can start at its
current count and ratchet down:
tjs check src/ --max-warnings 0
A missing or non-numeric value (--max-warnings alone) is a usage error: it exits with
status 2, so a typo cannot pass for a lint failure.
tjs run: executes, does not test
tjs run compiles a file and runs it as an ES module, with the full runtime installed.
$ tjs run broken.tjs
5
and it exits 0. run does not run signature examples or test blocks, so it says nothing
about whether a file is correct: the broken add above runs happily. Check first, then run:
tjs check broken.tjs && tjs run broken.tjs
tjs test: test blocks in one file
Given a .tjs file, tjs test runs that file's test blocks and signature examples and
lists each result, where check prints only failures. The number after each name is its
line:
$ tjs test src/mymath.tjs
✓ 'add and multiply':9
✓ Signature: add signature example:1
✓ Signature: multiply signature example:5
/…/my-project/src/mymath.tjs: 3 passed, 0 failed
Inconclusive tests
Tests run each file on its own, without its imports. So main.tjs's calculate example
cannot run: calculate calls add and multiply, which come from mymath.tjs. A test that
cannot run at all is inconclusive, and the three commands treat it differently:
| Command | An inconclusive test |
|---|---|
tjs test |
shown with ?; does not fail |
tjs check |
does not fail, and is not shown; the file gets a plain ✓ |
tjs emit |
counted as a failure: the file is not written (see below) |
So a ✓ from check means "nothing failed", not "every test ran". This is why the tests
belong in the module being imported; Local Module Imports explains
the pattern.
tjs test: *.test.tjs files under bun test
Given a directory, or no argument at all, tjs test does something else: it finds every
*.test.tjs file and runs them with bun test. These are ordinary Bun test files written in
TJS, using test(name, fn) and expect from bun:test. Use them for tests that need imports,
which a test block cannot have:
// math.test.tjs
import { test, expect } from 'bun:test'
import { add } from './mymath.tjs'
test('add from another file', () => {
expect(add(2, 2)).toBe(4)
})
-t pattern filters by test name, as in bun test.
A test '…' { } block inside a .test.tjs file is silently skipped on this route: only
test(name, fn) calls run.
tjs emit: compile to files
tjs emit writes JavaScript. With one file and no -o, it prints the JavaScript to standard
output; with -o it writes files:
tjs emit src/mymath.tjs > mymath.js
tjs emit src/ -o dist/ # every .tjs under src/ into dist/
tjs emit src/ -o dist/ --dts # plus a .d.ts per file
For each foo.tjs it writes foo.js and foo.md (documentation generated from the
signatures; --no-docs turns it off and --docs-dir <dir> puts it elsewhere). --debug adds
source locations to the metadata. A relative import './foo.tjs' is rewritten to
./foo.js, so the output tree runs without TJS.
emit runs each file's tests first and refuses to write a file whose tests fail, exiting
with status 1. --jfdi ("just do it") writes the file even when its tests fail.
One current wrinkle: emit also refuses a file whose tests were only inconclusive, which
check and test accept. Any file that imports another module and has a signature example
hits this, main.tjs included. So for now let check decide and emit write:
tjs check src/ && tjs emit src/ -o dist/ --jfdi
--jfdi is safe here only because of the &&: emit runs only after check has passed.
tjs types: the signatures as data
tjs types prints what TJS knows about each function as JSON: parameters, their types and
examples, and the return type.
$ tjs types src/mymath.tjs
{
"file": "src/mymath.tjs",
"add": {
"name": "add",
"params": {
"a": {
"name": "a",
"type": { "kind": "integer" },
"required": true,
"default": null,
"example": 0,
…
"default": null means the parameter has no default.
tjs convert: from TypeScript
tjs convert reads TypeScript. With --emit-tjs it writes .tjs source you can keep and
edit; without it, it writes .js:
tjs convert src/ -o tjs/ --emit-tjs # greet.ts -> greet.tjs
tjs convert src/ -o dist/ # greet.ts -> greet.js
Converted code keeps TypeScript's (that is, JavaScript's) behaviour, so by default it checks
nothing at runtime. Given this greet.ts:
function greet(name: string): string {
return `Hello, ${name}!`
}
the converted greet(42) returns 'Hello, 42!'. A /* @tjs TjsStrict */ comment at the top
of the .ts file opts it into full TJS, including input validation. Converting a codebase is
a book of its own; see Converting a Project.
The example project, run three ways
1. Directly under Bun. With the preload, Bun imports .tjs like any other module:
bun src/main.tjs
2. With the CLI.
tjs run src/main.tjs
3. As plain JavaScript, anywhere. Compile, then run the output with Node (or serve it to
a browser, or hand it to any bundler). --jfdi is there because main.tjs's example is
inconclusive (see tjs emit):
tjs check src/ && tjs emit src/ -o dist/ --jfdi
node dist/main.js
All three print:
calculate(3, 4) = 14
Expected integer for 'mymath.tjs:1:add.a', got number
The second line is add refusing 1.5. "Got number" means it got a number that is not an
integer. The check lives in mymath.tjs, so it travels with the function into every file
that calls it, and the error names where the function was declared: parameter a of add,
line 1 of mymath.tjs.
Three things go wrong often:
- Write the extension.
from './mymath.tjs'works;from './mymath'does not. Bun resolves the import and does not guess.tjs. - No preload, no
.tjsimports. Withoutbunfig.toml, Bun cannot load./mymath.tjs, and bothbunandtjs runstop withExport named 'add' not found in module '…/mymath.tjs'. A single file with no.tjsimports runs undertjs runeither way. - Point
checkat your sources.tjs check .also checksdist/and anything else ending in.js. Give itsrc/.
Under Bun: the runtime plugin
The bunfig.toml above is all Bun needs. The preload registers a loader that compiles each
.tjs file as it is imported, and installs the shared TJS runtime (see
The shared runtime) before any module runs.
It does not run signature examples or test blocks, for the same reason tjs run does not:
importing a module should not run its tests. To make a wrong worked example fail at import
time, under bun or bun test, set TJS_RUN_TESTS=1:
$ TJS_RUN_TESTS=1 bun broken.tjs
5
…
error: Transpile-time test failures:
Function signature example is inconsistent:
Expected 0 at 'add', got 5
…
It exits with status 1. As with check, the module's own console.log runs while the tests
do, and Bun adds a source frame and a stack trace (shown here as …).
It costs a test run per file loaded, so it suits CI better than everyday runs.
Bundling with Bun.build
The preload is a runtime registration, and Bun.build ignores it. To bundle a .tjs graph,
pass the build plugin:
// build.ts — run with: bun build.ts
import { tjsPlugin } from 'tjs-lang/bun-build-plugin'
const result = await Bun.build({
entrypoints: ['./src/main.tjs'],
outdir: './bundle',
plugins: [tjsPlugin({ runTests: true })],
})
if (!result.success) {
console.error(result.logs)
process.exit(1)
}
bundle/main.js then runs with node bundle/main.js and prints the same two lines.
The plugin takes two options, both off by default:
runTests: trueruns each file's examples and tests as it is bundled, so a wrong example fails the build. Inconclusive tests, likemain.tjs'scalculateexample, do not.sharedPrelude: trueconcerns the prelude, the small inline runtime each emitted file carries (see the next section). With it, every module imports one shared copy instead of inlining its own. The shared copy has every feature in it, so it pays off only when a bundle has many modules: for the two-file project above it makes the bundle larger (about 32 KB against 11 KB). Leave it off unless you have measured a saving. Use it only for a bundle built in one go; a module compiled against a different prelude (in practice, a different tjs-lang version) refuses to load.
Other bundlers (Vite, webpack, esbuild, Rollup) have no TJS plugin. Run tjs emit first and
give them the .js output.
What emitted JavaScript looks like
Compiling mymath.tjs gives a file in four parts.
An inline runtime at the top: const __tjs_rt = (() => { … })(), a few hundred bytes to a
few KB holding MonadicError, typeError and the other helpers the file actually uses. It is
what makes every emitted file standalone: it runs with no setup and no tjs-lang installed.
A __tjs binding, one line after it:
const __tjs = (globalThis.__tjs?.abi >= 2 ? globalThis.__tjs.createRuntime?.() : undefined) ?? { typeError: __tjs_rt.typeError, … }
When the module loads, it uses the installed shared runtime if there is one, and the inline
helpers otherwise. Your code calls __tjs.… either way, so the file never needs a global.
Because this line runs once, as the module is evaluated, a runtime installed later is not
seen (see The shared runtime).
Your code, with its checks. Each typed function starts by checking its arguments and returning an error if one is wrong:
export function add(a,b) {
__tjs.pushStack('mymath.tjs:1:add');
const __cv_a = __tjs_rt.__tv(a);
if ((typeof __cv_a !== 'number' || !Number.isInteger(__cv_a))) { return __tjs.typeError('mymath.tjs:1:add.a', 'integer', __cv_a); }
…
return a + b
}
(__tv unwraps a wrapped value, such as a reactive proxy, before the check.)
Metadata. After each function, its signature as data, which is a shorter form of what
tjs types prints and what Schema Validation reads at runtime:
add.__tjs = {
"params": {
"a": { "type": { "kind": "integer" }, "required": true, "default": null },
…
},
"returns": { "type": { "kind": "integer" } },
"source": "mymath.tjs:1"
}
The test blocks are stripped from the output. They run only when a tool asks for them:
check, test, emit, TJS_RUN_TESTS, runTests, or tjs() from code.
With --dts, tjs emit also writes a TypeScript declaration file, so TypeScript code can
import the output:
// dist/mymath.d.ts
export declare function add(a: number, b: number): number;
export declare function multiply(a: number, b: number): number;
TypeScript has no integer type, so integer becomes number. The declarations describe a
successful call; they do not say that add returns an error value when it refuses an
argument.
From your own code
tjs(source, options) from tjs-lang/lang is the transpiler every route above calls. It
returns an object, not a string. .code is the complete JavaScript, inline runtime
included; .body is only what your source became, without the runtime in front of it; and
.types is the signature as data:
import { tjs } from 'tjs-lang/lang'
const source = `
function double(n: 0): 0 {
return n * 2
}
`
const result = tjs(source, { filename: 'double.tjs' })
typeof result // 'object'
typeof result.code // 'string'
result.types.double.params.n.type.kind // 'integer'
// The dialect option is what a file extension decides for you
const eq = `const same = '5' == 5`
tjs(eq).body // "const same = __tjs_rt.Eq('5', 5)" (TJS equality)
tjs(eq, { dialect: 'js' }).body // "const same = '5' == 5" (JavaScript, unchanged)
The options you will reach for:
filename: used in error paths (double.tjs:2:double.n) and in each function'ssource.dialect: 'js' | 'tjs': which language the source is. A bare string is TJS.runTests: by defaulttjs()runs the signature examples andtestblocks and throws if one fails.'report'puts the results inresult.testResultsinstead of throwing;falseskips them.prelude: 'import': the programmatic form ofsharedPrelude. The output starts with animportofcreatePreludefromtjs-lang/preludeinstead of an inline runtime.
To run source with no import or export, evaluate .code and pick out what it defines:
import { tjs } from 'tjs-lang/lang'
import { isMonadicError } from 'tjs-lang/runtime'
const { code } = tjs('function double(n: 0): 0 { return n * 2 }')
const double = new Function(code + '\nreturn double')()
double(21) // 42
isMonadicError(double(1.5)) // true
(In a .tjs file, TJS warns about new Function, because it evaluates source with full
access to everything; that is why this example is plain JavaScript.)
That works only for a script. Source with export or import, like mymath.tjs, compiles
to an ES module, and new Function throws Unexpected keyword 'export'. Write the code to a
.mjs file and import() it instead.
A tool that accepts .tjs, .js and .ts
Every tool in this chapter maps extensions the same way, and tjs-lang/lang exports the
mapping so yours can too. sourceKindForFilename(name) returns 'tjs', 'js' or 'ts'.
dialectForFilename(name) returns 'js' for .js, .mjs and .cjs, and 'tjs' for
everything else, .ts included. So ask sourceKindForFilename first: TypeScript goes through
fromTS, which lives in its own entry point because it needs the TypeScript compiler, and
everything else goes straight to tjs():
import { readFileSync } from 'node:fs'
import { basename } from 'node:path'
import { tjs, dialectForFilename, sourceKindForFilename } from 'tjs-lang/lang'
import { fromTS } from 'tjs-lang/lang/from-ts'
export function compileFile(path: string): string {
const source = readFileSync(path, 'utf-8')
const filename = basename(path)
if (sourceKindForFilename(path) === 'ts') {
const converted = fromTS(source, { emitTJS: true, filename })
return tjs(converted.code, { filename }).code
}
return tjs(source, { filename, dialect: dialectForFilename(path) }).code
}
There is deliberately no single call that does all three: it would put the TypeScript
compiler in the bundle of every program that uses tjs-lang/lang. The converted TJS carries
a marker that tells tjs() it came from TypeScript, so the second call needs no dialect.
In the browser
tjs-lang/browser is a self-contained ES module (the transpiler with its parser built in),
so a page can load it from any CDN with no bundler and no import map:
const { tjs } = await import(
'https://cdn.jsdelivr.net/npm/tjs-lang/dist/tjs-browser.js'
)
const { code } = tjs(
"function greet(name: 'World'): 'Hello, World!' { return 'Hello, ' + name + '!' }"
)
const greet = new Function(code + '\nreturn greet')()
greet('Ada') // 'Hello, Ada!'
greet(42).message // "Expected string for '<source>:1:greet.name', got number"
A URL with no version loads whatever release is tagged latest; pin one
(…/npm/tjs-lang@<version>/dist/tjs-browser.js) in anything you ship.
For production, compile ahead of time with tjs emit and serve the .js; the page then
needs nothing from tjs-lang at all. Compiling in the page is for editors, playgrounds and
pages like this one. For TypeScript in the browser (tjs-lang/browser/from-ts), see
Build, CI and Editors.
The shared runtime
Every emitted file works alone, using its inline runtime. Installing the shared runtime
puts one full runtime on globalThis.__tjs, which every TJS module on the page or process
then reports to:
import { installRuntime } from 'tjs-lang/runtime'
installRuntime()
const { add } = await import('./dist/mymath.js')
add(1.5, 2)
__tjs.errors().length // 1
What it adds is the Flight Recorder: __tjs.errors(), the record of every
type error any module returned, including the ones your code ignored. Without an installed
runtime there is no __tjs global and no history. The same goes for configure() from
tjs-lang/runtime (log or throw type errors while debugging, see
Error Handling): emitted code reads those settings from the installed
runtime, so without one configure({ throwTypeErrors: true }) changes nothing.
Install it before the TJS modules load. Each module picks its runtime once, as it is
evaluated (the __tjs binding above), and a static import is evaluated before any of your
code runs, even code written above it. So load TJS modules with await import() after
installRuntime(), as above, or call installRuntime() in a separate module that you import
first. tjs run, the Bun preload and this book's pages install it for you.
What it is not needed for is isMonadicError. That function recognises an error from any
TJS module, compiled by any copy of tjs-lang, with or without a shared runtime, so checking a
result from another file always works:
import { isMonadicError } from 'tjs-lang/runtime'
import { add } from './dist/mymath.js'
isMonadicError(add(1.5, 2)) // true, no installRuntime() needed
Summary
| You want to… | Use |
|---|---|
| know whether the code is right | tjs check src/ (exit 1 on failure) |
| run a file | tjs run file.tjs, or bun file.tjs with the preload |
| see every test result | tjs test file.tjs |
import .tjs under Bun and bun test |
preload and [test] preload in bunfig.toml |
| fail on a wrong example at import time | TJS_RUN_TESTS=1 bun … |
| ship plain JavaScript | tjs emit src/ -o dist/ (plus --dts for TypeScript) |
| read signatures as data | tjs types file.tjs |
| convert TypeScript | tjs convert src/ -o tjs/ --emit-tjs |
| bundle with Bun | Bun.build with tjsPlugin() from tjs-lang/bun-build-plugin |
| compile in a page | tjs-lang/browser from a CDN |
| compile from code | tjs(source).code from tjs-lang/lang |
| keep a history of refused calls | installRuntime() before loading modules |