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:
- Part 1: Using TJS from TypeScript. Keep your
codebase. Import
.tjsmodules, turn on checks for one.tsfile, run untrusted code and agents in a sandbox. - Part 2: TypeScript to TJS, a syntax reference. What each TypeScript construct becomes in TJS, and the few places where TJS behaves differently.
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
- TypeScript Examples
- Converting a TypeScript Project
- Validating JSON at the Boundary
- Publishing a TJS Library to TypeScript
- Replacing Zod, io-ts and Ajv
- Build, CI and Editors
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 (
tjs convert): TypeScript becomes TJS source that keeps JavaScript's behaviour exactly. Where the code relied on a JavaScript quirk that TJS fixes, the converter writes a named escape such asDangerousLegacyEquals(a, b). Converted code does not validate inputs by default. - Graduate: a converted file becomes native TJS, still with the same behaviour, and you retire the escapes one at a time.
TjsStrict: a comment at the top of a.tsfile gives it full TJS at once: arguments are checked,==stops coercing,typeof nullis'null',newon a class declared in the file is an error,new Dateis refused, andprivatebecomes#private. Where TJS behaves differently lists these rules.
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:
- running user-provided or model-generated code safely (
Eval, AJS) - agents that travel over the network as data
- runtime checks at system boundaries (
TjsStricton the files that take input)
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
- TypeScript's
privateexists only at compile time. Conversion drops it. UnderTjsStrictit becomes a real#privatefield; in.tjs, write#xyourself. Fields are plain JavaScript fields and carry no example; the constructor's parameters are where the checks go. - Constructor and method parameters are checked like any function's.
- A TJS class is called:
Point(0, 0)does whatnew Point(0, 0)does.newon a class declared in the same file is a compile error in.tjs, because it adds nothing.newon a class imported from another module is left alone, so a TypeScript caller that writesnew Point(0, 0)gets the same object. Built-in classes still need it:new Map(),new Float32Array(4).
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.
varis a syntax error; useconstorlet.const!is compile-time immutability, with no runtime cost.new Date(…)is not allowed. ADateis mutable (d.setMonth(…)changes it for everyone holding it) and mixes a moment with a calendar date and the zone of whatever machine runs the code. TJS uses two plain values instead: a timestamp (milliseconds since 1970 UTC, the numberDate.now()returns, which is still allowed) and a legal date (a'YYYY-MM-DD'string), with theTimestampandLegalDatemodules of functions for each.LegacyDate(x)gives you a realDatewhere you need one, and conversion writes it for everynew Date(x). See Built-in Types.eval()is not allowed;Eval()is the sandboxed form (Part 1).new Functionis a warning.- A line that starts with
(,[or a backtick starts a new statement. JavaScript would join it to the line before, which is the classic missing-semicolon bug; TJS warns. A line still continues after a trailing operator, opening bracket or comma, and a line starting with.continues the one before, so chained calls formatted the Prettier way are unaffected. - A boxed
false(Object(false)) is falsy, andnew Boolean(…),new String(…)andnew Number(…)are refused: useBoolean(x)and friends, orObject(x)for a deliberate wrapper.
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.
- Proxies behave as in plain JavaScript. For custom equality, implement the
[tjsEquals]symbol (Symbol.for('tjs.equals')). - WeakMap / WeakSet are unaffected.
Object.defineProperty, getters and setters work as expected. A check reads values, not property descriptors.- Prototype chains are preserved, and
instanceofworks with TJS classes.
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:
- Null safety: a parameter accepts
nullonly if its type says| null. - Implicit any: every checked parameter has an example or a type name. An unannotated parameter accepts anything, as in JavaScript.
- Property access: a missing required property is a type error at the call, with its
location, rather than an
undefinedlater.
Appendix: what fromTS does with TypeScript
Constructs it converts
- Primitive annotations (
string,number,boolean) become checks.numberbecomes a float check, written0.0in some places andnumberin others (the same check) - Interfaces become
Typedeclarations - Generic interfaces become
Genericdeclarations, with adeclaration { … }block: the TypeScript body, copied into the generated.d.tsand removed from the JavaScript - String literal unions become
Union; enums becomeEnum - Function type aliases become
FunctionPredicatedeclarations, which check that a value is a function and record its parameters and return type for docs and.d.ts(see Declarations) T | nullbecomes a nullable check- Optional parameters and defaults are kept
- Rest parameters (
...args: number[]) keep their... privateis stripped (or becomes#privateunderTjsStrict)Promise<T>return types are unwrapped toT- DOM types become
{}, an opaque object, so the parameter stays annotated - Constrained generics use the constraint as the example;
<T = string>uses the default
@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:
- decorators, of either kind (
@Component,@Injectable, …) - auto-accessor fields (
accessor x = 1); write the field and its get/set pair instead - any class modifier or member kind the converter has no rule for yet, named in the error
.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
- TJS Language Reference: the language guide, feature by feature, with explanations
- TJS Syntax: the compact lookup table for every construct and rule
- TJS for JavaScript Programmers: coming from plain JavaScript
- AJS Agent Language: the sandboxed agent VM