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:

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:

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:

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