TJS for TypeScript Programmers

What if your types didn't disappear at runtime?


TypeScript checks your code against its types when it compiles, and then erases them. At run time a User parameter accepts whatever arrives: a JSON.parse result, a value cast with as any, a field the API renamed last week.

TJS keeps the types. A TJS type is an example value, and the example survives to run time, where it is a check, documentation and test data. This page is the reference for a TypeScript developer:

TypeScript: The Good, the Bad, and the Ugly makes the case for runtime types, and TJS vs TypeScript is a comparison table in which every row is executed against tsc --strict and TJS. The chapters below this page go deeper: TypeScript Examples to run in the page, Converting a TypeScript Project (the how-to for tjs convert and graduation), Validating JSON at the Boundary (where TypeScript's types are most often wrong), Publishing a TJS Library to TypeScript, Replacing Zod, io-ts and Ajv, and Build, CI and Editors.


In this section

Part 1: Using TJS from TypeScript

You don't have to rewrite anything. Everything in this part works from a TypeScript codebase.

Install

npm install tjs-lang      # or: bun add tjs-lang
bunx tjs check src/       # the tjs CLI, from the installed package

The library runs anywhere JavaScript does. The tjs command line tool runs on Bun, so it needs Bun installed even if your project ships for Node. Build, CI and Editors covers the setup.

Three ways to adopt TJS

This page mentions three routes, and they differ in what they change:

Convert and graduate change nothing until you choose to, which is why they are the safer route for a large codebase. TjsStrict is quicker for a file you are writing or that takes outside input.

Call a TJS function from TypeScript

A TJS function checks its arguments on every call. A bad argument does not throw: the function returns an error value, a MonadicError (a subclass of Error), and its body never runs. If you have used Rust's Result or Haskell's Either, this is the same idea: the error is a value the caller tests, here with isMonadicError.

The TypeScript return type does not include the error. tsc sees greet return a string, so code that calls TJS from TypeScript at a boundary has to check the result. That is a deliberate trade: the declared type stays the one you wrote, and the check happens where bad data actually arrives.

This example is TypeScript. The TjsStrict comment converts it to full TJS, so greet checks its User argument and the TypeScript caller handles the error as a value. isMonadicError is built into TJS code, so the declare line only tells tsc about it; in a project you would import { isMonadicError } from 'tjs-lang/runtime' instead:

/* @tjs TjsStrict */

declare function isMonadicError(value: unknown): value is Error

interface User {
  name: string
  age: number
}

function greet(user: User): string {
  return `Hello, ${user.name}`
}

// The caller: ordinary TypeScript, handling the error as a value
function handle(body: string): string {
  const result = greet(JSON.parse(body))
  if (isMonadicError(result)) return `rejected: ${result.message}`
  return result
}

console.log(handle('{"name": "Ada", "age": 36}'))
// Hello, Ada
console.log(handle('{"name": "Ada", "age": "36"}'))
// rejected: Expected User for '…greet.user': age: no alternative matched, got string
console.log(handle('null'))
// rejected: Expected User for '…greet.user', got null

tsc accepts all of this. The second and third calls are rejected at run time, and the message names the function, the parameter and the member that was wrong.

Import .tjs files from TypeScript

In a project the TJS function usually lives in its own .tjs file and TypeScript imports it. How the .tjs file is compiled decides whether tsc sees its types:

Workflow How .tjs is compiled Does TypeScript see the types?
Bun, at run time the tjs-lang/bun-plugin preload, on import no: needs a declare module '*.tjs' shim
Bun.build the tjs-lang/bun-build-plugin plugin no: needs the same shim
Node, or other bundler tjs emit --dts ahead of time, to .js yes: import the emitted .js and .d.ts

So tjs emit --dts is the way to get typed imports. With Bun, preload the plugin in bunfig.toml:

# bunfig.toml
preload = ["tjs-lang/bun-plugin"]

and .tjs files import like any other module:

// main.ts
import { isMonadicError } from 'tjs-lang/runtime'
// greet.tjs contains: export function greet(name: string):! string { … }
import { greet } from './greet.tjs'

const result = greet(42 as any)
if (isMonadicError(result)) console.log(result.message)
// Expected string for 'greet.tjs:1:greet.name', got number

The preload is a runtime registration, and Bun.build ignores it. To bundle, pass the build plugin:

import { tjsPlugin } from 'tjs-lang/bun-build-plugin'

await Bun.build({
  entrypoints: ['src/main.ts'],
  outdir: 'dist',
  plugins: [tjsPlugin()],
})

For Node or another bundler, compile .tjs to .js ahead of time with the tjs CLI (tjs emit src/ -o dist/). The emitted JavaScript needs no runtime package: each file carries the small runtime it uses. --dts adds a declaration file: tjs emit --dts greet.tjs -o dist/greet.js writes dist/greet.d.ts (export declare function greet(name: string): string), which types an import of the emitted greet.js. Build, CI and Editors covers the build setups, and Publishing a TJS Library to TypeScript covers shipping .d.ts files to TypeScript users.

Turn on checks for one TypeScript file

You do not have to write TJS to get runtime checks. Mark a .ts file /* @tjs TjsStrict */ and its interfaces become validators. This is TypeScript, converted and run on the page:

/* @tjs TjsStrict */

declare function isMonadicError(value: unknown): value is Error

interface Order {
  items: { id: number; qty: number }[]
  total: number
}

function processOrder(order: Order): { status: string } {
  return { status: `ok, ${order.items.length} items` }
}

function handle(body: string): string {
  const result = processOrder(JSON.parse(body))
  return isMonadicError(result) ? `rejected: ${result.message}` : result.status
}

console.log(handle('{"items": [{"id": 1, "qty": 2}], "total": 9.5}'))
// ok, 1 items
console.log(handle('{"items": [{"id": 1, "qty": "two"}], "total": 9.5}'))
// rejected: Expected Order for '…processOrder.order': items[0].qty: no alternative matched, got string

As with greet, processOrder is declared to return { status: string } and returns an error value instead when its argument is wrong, so the caller checks. Check where the result first arrives: an error value passed on to another checked function comes straight back out of it without running its body (see Errors are values).

TjsStrict gives the whole file every TJS rule at once (the list is in Three ways to adopt TJS), not only the checks. For a file you are migrating, Converting a TypeScript Project describes the route that changes nothing until you choose to. For checking API data specifically, see Validating JSON at the Boundary.

Run untrusted code: Eval and SafeFunction

tjs-lang/eval runs code you did not write (a user's formula, a model's output) in a fuel-metered sandbox, without eval() or new Function:

import { Eval, SafeFunction } from 'tjs-lang/eval'

// Run code once, with a fuel budget and only the values you pass in
const { result, error, fuelUsed } = await Eval({
  code: 'const cheap = items.filter(x => x.price < threshold)\nreturn cheap.length',
  context: { items: [{ price: 5 }, { price: 50 }], threshold: 10 },
  fuel: 1000,
})
if (error) console.log(error.message)
// result: 1, fuelUsed: about 2

// A runaway loop stops when the fuel is gone, and the failure is a value
const runaway = await Eval({ code: 'let n = 0\nwhile (true) { n = n + 1 }\nreturn n', fuel: 1000 })
// runaway.error.message: 'Out of Fuel'

// Or compile a reusable function
const underBudget = await SafeFunction({
  body: 'return items.filter(x => x.price < budget)',
  params: ['items', 'budget'],
  fuel: 500,
})
const { result: cheap } = await underBudget([{ price: 5 }, { price: 500 }], 100)
// cheap: [{ price: 5 }]

The code is AJS, a JavaScript subset (next section), so it reads as you expect, with a few rules of its own (no for loops, for one). Fuel is a budget of work: every operation the sandbox performs costs some (a loop iteration costs 0.1), so the snippet above used about 2 of its 1000, and the loop ran out after roughly 10,000 iterations. The code has no IO. It can reach the network or a store only through capabilities you pass in (capabilities: { fetch }). Errors come back as the error field, not as exceptions. Safe Eval covers the options.

Run agents: AJS

AJS is a JavaScript subset that compiles to a JSON AST, which a VM runs with a fuel limit and only the capabilities you inject. The AST is data: you can store it, send it, diff it and audit it. This agent runs on the page:

function shortlist({
  products = [
    { name: 'kettle', price: 40, rating: 4.6 },
    { name: 'toaster', price: 25, rating: 3.9 },
    { name: 'blender', price: 90, rating: 4.8 },
  ],
  budget = 50,
}) {
  let affordable = products.filter((p) => p.price <= budget)
  let names = affordable.map((p) => p.name)
  return { count: affordable.length, names }
}

It returns { count: 2, names: ['kettle', 'toaster'] }. From TypeScript, you transpile the source to an AST and run the AST. Transpiling is a separate step, so it can run where you choose (at build time, in a worker), and tjs-lang/vm-ast is the VM with no parser in it:

import { transpile } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'

const { ast } = transpile(`
function summarize({ data, query }) {
  let relevant = data.filter((x) => x.score > 0.5)
  let summary = llmPredict({
    prompt: 'Summarize for ' + query + ': ' + JSON.stringify(relevant),
  })
  return { query, summary, count: relevant.length }
}
`)

const vm = new AgentVM()
const { result, error } = await vm.run(
  ast,
  { data: [{ text: 'a', score: 0.9 }, { text: 'b', score: 0.2 }], query: 'q' },
  {
    fuel: 1000,
    timeoutMs: 5000,
    // llmPredict reads capabilities.llm.predict(prompt)
    capabilities: { llm: { predict: async (prompt) => callMyModel(prompt) } },
  }
)
// result: { query: 'q', summary: <whatever callMyModel returned>, count: 1 }

llmPredict is an atom: one of the built-in operations the VM provides. An agent calls atoms like functions, with no import. Some atoms work only on data inside the VM; others, like llmPredict, reach the outside world through a capability you inject (here capabilities.llm.predict), and without that capability they fail. Atoms take named inputs and refuse any they do not declare (llmPredict takes prompt and options). The AJS guide lists the atoms, the capabilities each one reads, and the budgets a run accepts.

Build agents in TypeScript: the builder

Agent builds the same kind of AST with a fluent API, without writing AJS source. It is lower level and more verbose than AJS, and nothing checks atom calls for you, so reach for it when a program generates agents (from a UI, a config file, another agent) rather than when a person writes one:

import { Agent, AgentVM } from 'tjs-lang'
import { s } from 'tjs-lang/schema'

const pipeline = Agent.take(s.object({ url: s.string }))
  .httpFetch({ url: { $kind: 'arg', path: 'url' } })
  .as('response')
  .varSet({
    key: 'results',
    value: {
      $expr: 'member',
      object: { $expr: 'ident', name: 'response' },
      property: 'items',
    },
  })
  .return(s.object({ results: s.array(s.any) }))

const vm = new AgentVM()
const { result } = await vm.run(
  pipeline.toJSON(),
  { url: 'https://api.example.com/search?q=kettle' },
  {
    fuel: 500,
    // a fetch capability returns DATA (parsed JSON), not a Response
    capabilities: { fetch: async (url) => ({ items: ['kettle', 'teapot'] }) },
  }
)
// result: { results: ['kettle', 'teapot'] }

s is tosijs-schema, re-exported by tjs-lang/schema. The builder writes a version-1 AST, the format ASTs were first stored in. It is not deprecated: the VM keeps running it so that stored agents keep working. The difference from the version 2 that the transpiler writes is how a bare string is read (in version 1, a string that names a variable in scope is that variable); Builder API has the details.

Entry points

The package is split so you import only what you use:

import { Agent, AgentVM, ajs, tjs } from 'tjs-lang' // most things (not fromTS)
import { Eval, SafeFunction } from 'tjs-lang/eval' // safe eval only
import { tjs, transpile } from 'tjs-lang/lang' // language tools
import { fromTS } from 'tjs-lang/lang/from-ts' // TS -> TJS converter (loads TypeScript)
import { AgentVM } from 'tjs-lang/vm-ast' // the agent VM, no parser
import { isMonadicError, TEmail } from 'tjs-lang/runtime' // runtime helpers and types
import { s, validate } from 'tjs-lang/schema' // tosijs-schema

Only tjs-lang/lang/from-ts needs the typescript package installed. The two AgentVMs are the same VM: the main entry's also accepts AJS source and parses it, while tjs-lang/vm-ast takes only an AST and has no parser in it, which is why it is the recommended one for running agents.

Tooling

Concern TypeScript TJS
Type checking tsc, at compile time Checks emitted into the code, at run time; tjs check compiles and runs examples and tests
Runtime schemas Zod / io-ts / Ajv, kept in sync The type is the schema; Type(…).toJSONSchema() exports JSON Schema
Linting ESLint and plugins Built-in checks (unused variables, unreachable code, new on your own class) in tjs check
Testing Vitest / Jest, separate files Inline test blocks and worked return examples, run by tjs check / tjs test
Build tsc plus a bundler tjs CLI (runs on Bun), the Bun runtime plugin, the Bun.build plugin
Declarations .d.ts from tsc tjs emit --dts
Documentation JSDoc / TypeDoc tjs emit writes Markdown docs from signatures and doc comments
Editor support Full language service Highlighting for .tjs and .ajs: VS Code (npx ajs-install-vscode), Cursor (npx ajs-install-cursor), Monaco, CodeMirror, Ace

tjs check is the gate: it compiles a file (or every file in a directory), lints it, and runs its signature examples and test blocks, exiting non-zero on any failure. tjs test runs the test blocks of a file or of .test.tjs files, reports each one, and can filter by name (-t). tjs emit also runs them and refuses to write output when one fails.

The editor support is highlighting only: there is no TJS language server, so you get no type-aware completion or go-to-definition in .tjs files yet. If you are replacing a runtime schema library, see Replacing Zod, io-ts and Ajv.

When to stay in TypeScript

If your codebase is TypeScript and you are happy with it, you may only need TJS for:

None of that requires migrating anything.


Part 2: TypeScript to TJS, a syntax reference

The core idea: types as examples

TypeScript describes a type abstractly. TJS shows an example of it:

// TypeScript: what TYPE is this?
function greet(name: string): string { ... }

// TJS: what is an EXAMPLE of this?
function greet(name: 'World'): 'Hello, World!' { ... }

name: 'World' means "a required string; here is a valid one". It does not mean the argument must be 'World': any string passes. The example documents the parameter and gives TJS a value to test with.

The return annotation is different, and the difference matters. : 'Hello, World!' is a worked example: TJS calls greet('World') and compares the result with 'Hello, World!', and a wrong body is a failure. This happens when the file is checked or compiled: tjs check, tjs test and tjs emit run every worked example (and emit refuses to write output if one fails), and so does the live runner on these pages. The Bun plugin runs them on import only if you set TJS_RUN_TESTS=1.

Because the function really is called, a function with side effects (one that writes a file, sends a request or reads state that is not there yet) should not have a worked example. Write :! instead: it states the return type and runs nothing. Examples as contracts explains everything else an example does.

Primitives

// TypeScript                    // TJS
name: string                     name: ''
count: number                    count: 0.0       // any number
index: number                    index: 0         // integer
age: number                      age: +0          // non-negative integer
flag: boolean                    flag: true
items: string[]                  items: ['']
nested: number[][]               nested: [[0]]

TJS distinguishes three numeric types, using spellings that are already legal JavaScript:

You write Type name Runtime check
3.14, 0.0 float any number, integers included
42, 0 int Number.isInteger(x)
+20, +0 unsigned Number.isInteger(x) && x >= 0

x: float, x: int and x: unsigned are the same three checks written as type names, and x: number means the same as x: float.

The converter maps TypeScript's number to 0.0, the widest of the three, so converted code accepts what it accepted before. Narrow to 0 or +0 by hand where that is what you mean.

TypeScript's type names work too. name: string, count: number, flag: boolean and items: string[] are valid TJS and real runtime checks: s: string means exactly what s: '' means. The example form is the native spelling because it also gives a value to document and test with.

An array example such as items: [''] checks every element, and an empty array passes. The check is a loop over the array, so it costs time in proportion to its length (well under a millisecond for 100,000 strings), and a failure names the bad element: Expected string for '…items[99999]', got number.

Optional parameters

// TypeScript                    // TJS
function f(x?: string) {}        function f(x: '' | undefined) {}
function f(x: string = 'hi') {}  function f(x = 'hi') {}

x: '' | undefined keeps TypeScript's meaning: the argument may be left out, and then x is undefined inside the body. It is what the converter writes for x?: string.

= value makes a parameter optional with a default, and the default is also its example: x = 'hi' accepts any string or nothing. That is a different function from x?: string, because an omitted x is now 'hi', so code that tests x === undefined stops seeing undefined. Use a default only where you want one.

Object shapes

function createUser(opts: { name: '', age: 0.0, email: '' | undefined }) {
  return opts.email ? `${opts.name} <${opts.email}>` : opts.name
}

console.log(createUser({ name: 'Ada', age: 36 }))                             // Ada
console.log(createUser({ name: 'Ada', age: 36, email: 'ada@example.com' }))   // Ada <ada@example.com>
console.log(createUser({ name: 'Ada' }).message)
// Expected number for '…createUser.opts.age', got undefined

That is TypeScript's opts: { name: string; age: number; email?: string }. Every member of a shape is required unless its type admits undefined: email: '' | undefined means "may be absent; if present, a string". Writing email = '' inside a shape is a syntax error, because = gives defaults to parameters, not to members of a parameter's type. For per-member defaults, make the whole parameter a dictionary: opts = { name: '', age: 0 } fills in whatever the caller leaves out (see Dictionary Defaults).

Destructured parameters

TypeScript's function area({ width, height }: { width: number; height: number }) is, in TJS:

function area({ width: 0.0, height: 0.0 }) {
  return width * height
}

test 'the argument is checked as one thing' {
  expect(area({ width: 2, height: 3 })).toBe(6)
  const notASize = { width: 'two', height: 3 }
  expect(area(notASize).received).toBe(notASize)
  expect(isMonadicError(area(null))).toBe(true)
}

The members carry their examples directly. The TypeScript spelling, a pattern followed by : Shape, is accepted and checked too. Use it when the pattern cannot hold the type: a named type (({ x, y }: Point)), a rename (({ w: width }: Size), since in a pattern a colon followed by a name IS a rename), or a default inside the pattern (({ x = 0, y = 0 }: Point)). Either way the argument is checked as one value: null, a string or an Error you made is a type error, and an error value from an earlier call passes through unchanged.

A type error carries what you need to report it: message (the readable text), path (file, line, function and parameter, such as '…:1:area.width'), expected and actual (the expected type and the typeof of what arrived), and received (the value that was passed). The tests here use isMonadicError(x); x instanceof Error is also true of a type error, but it is true of any other Error too.

Return types

// TypeScript                                 // TJS
function add(a: number, b: number): number    function add(a: 2, b: 3): 5
function getUser(): { name: string }          function getUser():! { name: '' }
function fetchData(): Promise<string[]>       async function fetchData():! ['']
Spelling Meaning
: 5 worked example: add(2, 3) is called when the file is checked; must be 5
:! T the return type only; nothing runs, results are not checked
:? 5 the worked example, and every result is also checked at run time

fromTS writes :! for every TypeScript return type. Write it yourself for a function whose result depends on IO or on state that a check-time call would not have: getUser() above presumably reads a session or a database, so calling it while checking the file would fail or do harm. An async function is annotated with what it resolves to, not with Promise<…>. function add(a: 2, b: 3): 0 fails tjs check, because add(2, 3) is 5.

Interfaces and type aliases

interface User {
  name: string
  age: number
  email?: string
}

type Status = 'active' | 'inactive' | 'banned'

becomes a Type and a Union, both of which are runtime values with a check:

Type User {
  description: 'a registered user'
  example: { name: '', age: 0.0, email: '' | undefined }
}

Union Status 'account status' 'active' | 'inactive' | 'banned'

function register(user: User, status: Status) {
  return `${user.name}: ${status}`
}

console.log(register({ name: 'Ada', age: 36.5 }, 'active'))
// Ada: active
console.log(register({ name: 'Ada', age: 36, email: 42 }, 'active').message)
// Expected User for '…register.user', got object
console.log(register({ name: 'Ada', age: 36 }, 'deleted').message)
// Expected Status for '…register.status', got string
console.log(User.check({ name: 'Ada' }))
// false: check answers true or false, it does not return an error

The string after a Union or Enum name ('account status') is its description, the same thing description: is in a Type block. It is optional.

age: 0.0 and email: '' | undefined are exactly what the converter writes for age: number and email?: string.

Enums

enum Color {
  Red = 'red',
  Green = 'green',
  Blue = 'blue',
}

enum Dir {
  Up,
  Down,
}

become Enum declarations, and member access works as it does in TypeScript: Color.Red is 'red', and a numeric enum maps back from value to name (Dir[0] is 'Up'). An Enum is also a type, so a parameter declared c: Color rejects anything that is not one of its values:

Enum Color 'CSS color' {
  Red = 'red'
  Green = 'green'
  Blue = 'blue'
}

Enum Dir 'direction' {
  Up
  Down
}

function paint(c: Color) {
  return `painting ${c}`
}

console.log(Color.Red, Color.Red === 'red', Dir.Up, Dir[0])  // red true 0 Up
console.log(paint(Color.Green))                               // painting green
console.log(paint('purple').message)
// Expected Color for '…paint.c', got string

// Extras a TypeScript enum does not have
console.log(Color.check('red'), Color.values, Color.names['red'])
// true ['red', 'green', 'blue'] Red

Color.check(v) answers whether v is a member's value, Color.values lists the values, and Color.names maps each value back to its member's name.

The members are non-enumerable, so Object.keys(Color) does not list them; Color.values and Color.members ({ Red: 'red', … }) do. Converted TypeScript enums work the same way:

/* @tjs TjsStrict */

enum Color {
  Red = 'red',
  Green = 'green',
}

enum Dir {
  Up,
  Down,
}

console.log(Color.Red, Dir.Up, Dir[0])  // red 0 Up

// The Enum's extras are there too; tsc does not know about them, hence the cast
console.log((Color as any).check('red'), (Color as any).check('purple'))  // true false

One gap: in converted TypeScript, a parameter typed with an enum (c: Color) is not checked yet. The converter marks it with a TODO: TS types degraded comment. Write the function in TJS, as above, or check explicitly with Color.check(c).

Classes

class Point {
  private x: number
  private y: number

  constructor(x: number, y: number) {
    this.x = x
    this.y = y
  }

  distanceTo(other: Point): number {
    return Math.sqrt((this.x - other.x) ** 2 + (this.y - other.y) ** 2)
  }
}

const p = new Point(10, 20)

In TJS:

class Point {
  #x
  #y

  constructor(x: 0.0, y: 0.0) {
    this.#x = x
    this.#y = y
  }

  distanceTo(other: Point):! 0.0 {
    return Math.sqrt((this.#x - other.#x) ** 2 + (this.#y - other.#y) ** 2)
  }
}

const p = Point(0, 0) // a class is called, not constructed
console.log(p.distanceTo(Point(3, 4)))      // 5
console.log(isMonadicError(Point('a', 0)))  // true

Generics

TypeScript's type parameters (<T>) vanish at run time. TJS has no function-level generics; it has Generic declarations, which produce runtime-checkable type constructors. Inside the predicate, each type parameter arrives as a check function: T(v) is true when v is a valid T.

Generic Box<T> {
  description: 'a boxed value'
  predicate(x, T) {
    return typeof x === 'object' && x !== null && 'value' in x && T(x.value)
  }
}

// Box(0) is the type "a Box of integers"
const IntBox = Box(0)
console.log(IntBox.check({ value: 42 }), IntBox.check({ value: 'nope' }))  // true false

When the type is a shape with the parameter in it, Type Box<T> with an example is shorter: the example says where T goes, and TJS builds the predicate. Use Generic when the check is more than a shape (a condition across members, say). Either can be applied in an annotation:

Type Box<T> {
  example: { value: T }
}

function unbox(b: Box<int>) {
  return b.value
}

test 'Box<int> checks the value slot' {
  expect(unbox({ value: 1 })).toBe(1)
  expect(isMonadicError(unbox({ value: 1.5 }))).toBe(true)
}

A generic function such as identity<T> or first<T> has no TJS equivalent that keeps the relationship between its types. TJS checks the values a function receives, not that the result has the same type as the input, so write the loosest parameter that is right: function first(arr: [any]) { return arr[0] } accepts an array of anything, which is what TypeScript's first<T>(arr: T[]) accepts. (arr: [0] would accept only arrays of integers.) What you lose is the static link from argument to result type.

When converting, a constrained generic (<T extends { id: number }>) uses its constraint as the example, and a default (<T = string>) uses the default. An unconstrained <T> becomes any, which checks nothing; the converter warns about each one.

Nullability

// TypeScript
function find(id: number): User | null { ... }

// TJS
function find(id: 0):! { name: '', age: 0 } | null { ... }

TJS distinguishes null from undefined. | null means the base type or null, but not undefined. (TJS also changes typeof null to 'null'; see Equality and typeof.) A parameter with a default (=) accepts undefined, because that is what an omitted argument is.

Discriminated unions

A discriminated union is a set of types that differ in one literal member. Exactly('circle') makes that member exact (a plain 'circle' example would mean "any string").

TJS handles the dispatch with polymorphic functions: several declarations with the same name merge into one function that picks a variant by its arguments. It chooses by number of arguments first, then by how specific the types are, then by declaration order, and two variants with the same types at the same arity are a compile error. See Polymorphic functions.

Type Circle { example: { kind: Exactly('circle'), r: 1.0 } }
Type Rect { example: { kind: Exactly('rect'), w: 1.0, h: 1.0 } }

function shapeArea(s: Circle) { return Math.PI * s.r ** 2 }
function shapeArea(s: Rect) { return s.w * s.h }

test 'dispatch on the discriminant' {
  expect(shapeArea({ kind: 'rect', w: 2, h: 3 })).toBe(6)
  expect(shapeArea({ kind: 'circle', r: 1 })).toBe(Math.PI)
  expect(isMonadicError(shapeArea({ kind: 'triangle', b: 2, h: 3 }))).toBe(true)
}

There is no switch on kind and no exhaustiveness check to remember: a value that matches no variant is an error value, not a fall-through.

Predicates instead of type-level programming

TypeScript's type system can express constraints like Pick<Omit<T, K>, Extract<keyof T, string>>, but such types are often harder to read than the code they describe, and they cannot check data at run time. In TJS, a type with a predicate is a boolean function. If you can write the test, you can define the type:

// TypeScript: a branded type, a type guard, and a validator to keep in step
type Email = string & { __brand: 'email' }
function isEmail(s: string): s is Email {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)
}

The TJS version below checks the address with string methods instead of the regex. That is a choice, not a limitation: TJS verifies a predicate written this way as pure (no side effects) and bounded (it always finishes, whatever string it is given), which it cannot prove of a regex literal. So a verified predicate is safe to run on any input, however hostile, without a slow or runaway case. A regex works too; tjs check only warns that it cannot verify it.

Type Email {
  description: 'email address'
  example: 'user@example.com'
  predicate(v) {
    if (typeof v !== 'string' || v.includes(' ')) return false
    const at = v.indexOf('@')
    return at > 0 && v.indexOf('.', at) > at + 1 && !v.endsWith('.')
  }
}

Type PositiveInt {
  description: 'a positive integer'
  example: 1
  predicate(v) { return Number.isInteger(v) && v > 0 }
}

function contact(email: Email | null, seats: PositiveInt) {
  return `${email ?? 'no email'}, ${seats} seats`
}

console.log(contact('ada@example.com', 2))           // ada@example.com, 2 seats
console.log(contact(null, 1))                        // no email, 1 seats
console.log(contact('ada@example', 1).message)       // Expected Email for '…contact.email', got string
console.log(contact('ada@example.com', 0).message)   // Expected PositiveInt for '…contact.seats', got number
What you want TypeScript TJS
String with format branded type, type guard, validation function Type Email { predicate(v) {…} }
Number in range branded type, manual check Type PositiveInt { predicate(v) {…} }
Nullable variant T | null (compile time only) x: MyType | null (checked)
Union of literals 'a' | 'b' | 'c' (compile time only) Union Status 'a' | 'b' | 'c'
Discriminated union { kind: 'circle' } | … plus narrowing kind: Exactly('circle'), overloads
Generic container interface Box<T> (compile time only) Generic Box<T> { predicate(…) }

TJS also ships common types: TString, TNumber, TBoolean, TInteger, TPositiveInt, TNonEmptyString, TEmail, TUrl, TUuid, and the combinators Nullable and Optional, all exported by tjs-lang/runtime, which also exports the date modules Timestamp and LegalDate (see Other rules).

TypeScript features and their TJS equivalents

TypeScript feature In TJS
interface Type with an example
type aliases Type, Union or Enum
Conditional, mapped types kept verbatim for .d.ts output; nothing is checked at run time
keyof, typeof in types runtime Object.keys(), typeof
Partial<T>, Pick<T> write the shape you need
.d.ts files generated by tjs emit --dts
as type assertions not needed: values are checked
any escape hatch x: any for one parameter; ! or safety none turn checks off (Safety controls)
Decorators not supported; conversion stops with an error
namespace use modules

Decorators are the one gap here that blocks code outright: tjs convert refuses a file that uses them (legacy experimentalDecorators or the standard kind) and names the site, rather than dropping them silently. A codebase built on a decorator framework such as Angular, NestJS or TypeORM stays in TypeScript; it can still call TJS modules, as Part 1 shows.

Where TJS behaves differently

A .tjs file fixes a few JavaScript behaviours that TypeScript inherits. Converted TypeScript keeps JavaScript's behaviour until you graduate it or mark it TjsStrict.

Equality and typeof

// == is honest: no coercion, boxed primitives unwrapped
console.log(0 == '')                     // false (JavaScript: true)
console.log(Object('foo') == 'foo')      // true
console.log(null == undefined)           // true (kept: it is useful)
console.log(typeof null)                 // 'null' (JavaScript: 'object')

// == is not structural: objects and arrays compare by reference
const a = { x: 1 }
console.log(a == { x: 1 })               // false

// Is / IsNot compare structure, explicitly
console.log(Is({ a: 1 }, { a: 1 }))                // true
console.log(Is([1, 2, 3], [1, 2, 3]))              // true
console.log(Is(new Set([1, 2]), new Set([2, 1])))  // true (sets ignore order)

// === is JavaScript's, unchanged
const n = NaN
console.log(a === a, n === n)                       // true false
console.log(Object('foo') === 'foo')                // false

== drops coercion without the cost of a deep comparison. === keeps exactly JavaScript's meaning, so NaN === NaN is still false and a boxed string is not === its value; use == when you want those to be equal. a Is b also works as an infix operator, but the call form Is(a, b) is the recommended spelling, because it reads the same wherever it appears.

typeof is unchanged except for null, which is 'null' rather than 'object'. Code that tested typeof x === 'object' to find objects no longer needs a separate x !== null. LegacyTypeOf(x) gives JavaScript's answer.

If converted code relies on == coercion or on typeof null being 'object', conversion writes DangerousLegacyEquals(a, b) or LegacyTypeOf(x) for it, so nothing changes silently. Equality, typeof and truthiness has the full table.

Errors are values

A TJS function does not throw for a bad argument; it returns a MonadicError, and a MonadicError passed to another TJS function comes straight back out without the body running:

function divide(a: 6.0, b: 3.0): 2.0 {
  if (b === 0) return new MonadicError('Division by zero')
  return a / b
}

function double(x: 1.0): 2.0 {
  return x * 2
}

console.log(double(divide(9, 3)))  // 6

const bad = divide(10, 0)
const passed = double(bad) // returns bad; the body does not run
console.log(isMonadicError(passed), passed === bad, passed.message)
// true true Division by zero

There is no try/catch, and a caller that forgets to check does not crash: the error travels on as a value until something tests it with isMonadicError. (MonadicError is a built-in class, so it takes new.)

The cost is the obvious one: an error nobody checks can travel a long way and end up stored, or printed as text, far from where it happened. TJS gives you two ways to notice. The runtime keeps a record of recent type errors, which __tjs.errors() returns (handy in a test: run the code, then expect the list to be empty). And while debugging, configure({ logTypeErrors: true }) logs each one as it happens, and configure({ throwTypeErrors: true }) makes them throw instead (both from tjs-lang/runtime).

Runtime Validation shows errors as values from converted TypeScript.

Safety controls

safety none       // this module: no validation
safety inputs     // this module: validate inputs (the default in a .tjs file)
safety all        // this module: currently the same as inputs

function hot(! x: 0, y: 0) {}   // this function: skip validation of all its parameters

The default depends on the kind of file: a .tjs file validates inputs, while converted TypeScript starts at safety none until you graduate it or mark it TjsStrict. The ! goes at the start of the parameter list and turns off every check in that function; there is no per-parameter !, so to relax one parameter give it a looser type (x: any).

safety all validates the same as safety inputs today. To check a function's results at run time, give it a :? return annotation. Skip validation with ! only where the data is already trusted and the call is hot: on trivial bodies, checked calls measure about 1.5 to 2 times the cost of unchecked ones (benchmarks), which is noise next to most real work.

Other rules of .tjs

These apply to .tjs files and to .ts files marked TjsStrict. Converted TypeScript does not get them: the converter writes a named escape wherever the code depended on the old behaviour, so it keeps running as before.

TJS Syntax lists every rule and its named escape.

Side by side: a complete example

interface Product {
  id: string
  name: string
  price: number
  tags: string[]
}

interface CartItem {
  product: Product
  quantity: number
}

function calculateTotal(items: CartItem[], taxRate: number = 0.1): number {
  const subtotal = items.reduce(
    (sum, item) => sum + item.product.price * item.quantity,
    0
  )
  return Math.round(subtotal * (1 + taxRate) * 100) / 100
}

function applyDiscount(
  total: number,
  code: string | null
): { final: number; discount: number } {
  const discounts: Record<string, number> = { SAVE10: 0.1, SAVE20: 0.2 }
  const rate = code ? discounts[code] ?? 0 : 0
  return {
    final: Math.round(total * (1 - rate) * 100) / 100,
    discount: rate,
  }
}

In TJS, translated by hand (the converter's output would keep quantity as any number). Three things to read in it: quantity: +0 narrows the quantity to a non-negative integer, which TypeScript's number cannot say; code: 'SAVE10' | null means "any string, or null", with 'SAVE10' as the example; and applyDiscount's return annotation is a worked example, so checking the file calls applyDiscount(100, 'SAVE10') and expects { final: 90, discount: 0.1 }:

Type Product {
  description: 'a product in the catalog'
  example: { id: '', name: '', price: 0.0, tags: [''] }
}

Type CartItem {
  description: 'a product with quantity'
  example: { product: Product, quantity: +0 }
}

function calculateTotal(items: [CartItem], taxRate = 0.1):! 0.0 {
  const subtotal = items.reduce(
    (sum, item) => sum + item.product.price * item.quantity,
    0
  )
  return Math.round(subtotal * (1 + taxRate) * 100) / 100
}

function applyDiscount(total: 100.0, code: 'SAVE10' | null): { final: 90.0, discount: 0.1 } {
  const discounts = { SAVE10: 0.1, SAVE20: 0.2 }
  const rate = code ? discounts[code] ?? 0 : 0
  return {
    final: Math.round(total * (1 - rate) * 100) / 100,
    discount: rate,
  }
}

test 'cart calculation' {
  const items = [
    { product: { id: '1', name: 'Widget', price: 10, tags: [] }, quantity: 3 }
  ]
  expect(calculateTotal(items, 0)).toBe(30)
  expect(calculateTotal(items, 0.1)).toBe(33)
  expect(isMonadicError(calculateTotal([{ product: items[0].product, quantity: -1 }]))).toBe(true)
}

test 'discount codes' {
  expect(applyDiscount(100, null)).toEqual({ final: 100, discount: 0 })
  expect(applyDiscount(100, 'INVALID')).toEqual({ final: 100, discount: 0 })
}

It is about the same length. The types exist at run time (a negative quantity is rejected), the signature of applyDiscount is tested, and the tests live beside the code.

Converting a codebase

tjs convert turns TypeScript into TJS (or straight into JavaScript):

tjs convert src/label.ts --emit-tjs     # one file, TJS on stdout
tjs convert --emit-tjs src/ -o tjs/     # a directory, to .tjs files
tjs convert src/ -o js/                 # a directory, to .js

Conversion does not change behaviour: the output keeps JavaScript's semantics, and where the TypeScript relied on a JavaScript behaviour that TJS fixes, it writes a named escape (DangerousLegacyEquals(a, b), LegacyTypeOf(x)). Graduation then makes the file native TJS, still with the same behaviour, and you retire the escapes one at a time. TjsStrict skips both steps (see Three ways to adopt TJS). Converting a TypeScript Project walks through it; the appendix below lists what the converter does with each TypeScript construct. From code, fromTS(source, { emitTJS: true }).code (from tjs-lang/lang/from-ts) is the TJS source.

Error locations instead of source maps

Every TJS function carries metadata, including where it was declared: add.__tjs.source is 'mymodule.tjs:3', the file and line of the declaration. Type errors use it: a rejected argument reads Expected string for 'greet.tjs:1:greet.name', got number, which names the file, line, function and parameter without a source map.

That covers type errors, which are the ones a runtime-checked language adds. It does not replace source maps for everything: a stack trace from a thrown exception, or a breakpoint in a debugger, points at the emitted JavaScript. That JavaScript keeps your function bodies as written, so it is readable, but a bundler will still move it around.

FAQ

How do I use TJS with existing npm packages?

As usual. JavaScript is valid TJS when it is compiled as JavaScript (a .js file, or the dialect: 'js' option), and a .tjs file adds the rules above on top of it. Those rules apply to your file, not to the packages it imports, which are ordinary JavaScript:

import lodash from 'lodash'
import { z } from 'zod' // works, though you may not need it

// in the browser without a bundler, a CDN URL works too
import dayjs from 'https://esm.sh/dayjs@1.11.13'

A plain JavaScript library has no TJS metadata, so nothing checks what it returns. Wrap it at your boundary:

// Stands in for an untyped library: import { rawGeocode } from 'legacy-geo-pkg'
function rawGeocode(addr) {
  return addr.includes('nowhere') ? { lat: 'unknown' } : { lat: 51.5, lon: -0.12 }
}

Type Coords {
  example: { lat: 0.0, lon: 0.0 }
}

function geocode(addr: 'London') {
  const result = rawGeocode(addr)
  return Coords.check(result)
    ? result
    : new MonadicError(`geocoder returned ${JSON.stringify(result)}`)
}

console.log(geocode('London'))
console.log(geocode('nowhere').message)

The library code runs freely, and the wrapper checks its result before it enters your typed code. Why not function geocode(addr: ''): { lat: 0.0, lon: 0.0 }? A plain return annotation is a worked example, so TJS would call geocode(''), and with it the library, at build time. :! documents a return type without checking it; an explicit check, as here, is the way to validate a value from code you do not control.

Do Proxies, WeakMaps and other advanced patterns work?

Yes. TJS adds checks at function entry and a __tjs metadata property on functions; it does not wrap or intercept JavaScript objects.

Does Is handle circular references?

Yes. Is and IsNot detect cycles, so two structures that refer to themselves in the same way compare equal, and the comparison terminates. == never recurses at all: it compares objects by reference.

What happens to TypeScript's strict checks?

TJS has no strictNullChecks or noImplicitAny, because it checks values instead:


Appendix: what fromTS does with TypeScript

Constructs it converts

@tjs annotations

Comments of the form /* @tjs … */ in a .ts file enrich the TJS output. tsc ignores them.

/* @tjs TjsStrict */ // the whole file as full TJS

/* @tjs-skip */ // leave this declaration out
export type Unboxed<T> = T extends { value: infer U } ? U : T

/* @tjs predicate(x, T) { return typeof x === 'object' && T(x.value) } */
export interface Box<T> {
  value: T
}

/* @tjs example: { name: 'Alice', age: 30 } */
export interface User {
  name: string
  age: number
}

An example: annotation is taken literally, so age: 30 here makes age an integer.

What it can't fully express

TypeScript pattern What happens
Conditional types (T extends U ? X : Y) kept verbatim in a declaration block; checks nothing
Mapped types ({ [K in keyof T]: … }) kept verbatim in a declaration block; checks nothing
Intersection types (A & B) kept as a comment in an empty Type; checks nothing
Partial<T>, ReturnType<T> and other utility types any, with a warning
Template literal types (`user-${string}`) any
readonly string[] any (use const! for compile-time immutability)
Unconstrained generics (<T>) any, with a warning
Enum-typed parameters (c: Color) any (the Enum itself converts)

Every parameter that ends up as any gets a TODO: TS types degraded comment in the converted output. Search for it: each one is a check you can add by hand.

A namespace converts to the var NS; (function (NS) { … })(NS || (NS = {})) code TypeScript itself emits. It runs, but var is not allowed in native TJS, so rewrite the namespace as a module before you graduate the file.

What it refuses

Some constructs cannot be converted without changing what the code does. For those, conversion stops with a FromTSRefusal error that names the construct and its file, line and column, rather than dropping it:

.d.ts generation

tjs emit --dts file.tjs -o out/file.js writes out/file.d.ts beside the JavaScript: functions, classes (as callable functions plus a class declaration), constants, and the TypeScript bodies kept in declaration blocks, so TypeScript consumers of a converted library still see conditional and mapped types.


Learn more