TJS Syntax Reference
Every TJS construct, briefly, with what it does and a working example. It is ordered the way
you look things up: what goes in a signature first, then the rules a .tjs file follows,
then the rest of the language. For a guided introduction, start with
Hello TJS. New to programming? Start with
Learn to Program.
One idea runs through all of it. The value after a colon is an example, not a type.
name: 'Alice' means "a required string, for instance 'Alice'"; it is not TypeScript's
string-literal type. The example tells TJS what to check at runtime, and it gives the docs
and the tests a real value to work with.
The examples on this page run in the page. Their output is shown in the comments.
Parameters
A parameter's example decides what the function accepts. A call with the wrong kind of value does not run the body:
function greet(name: 'Alice') { return `Hello, ${name}` } // a required string
function scale(rate: 3.14) { return rate * 2 } // any number
function repeat(count: 42) { return 'x'.repeat(count) } // an integer (whole number)
function at(index: +0) { return ['a', 'b', 'c'][index] } // a non-negative integer
console.log(greet('Ada')) // Hello, Ada
console.log(scale(2)) // 4
console.log(repeat(3)) // xxx
console.log(isMonadicError(greet(42))) // true: 42 is not a string
console.log(isMonadicError(repeat(1.5))) // true: repeat's example (42) is an integer
console.log(isMonadicError(at(-1))) // true: at's example (+0) is non-negative
A refused call returns an error value instead of throwing. isMonadicError(x), which TJS
provides, tells you whether you got one; Errors says what the error holds.
The numeric examples are ordinary JavaScript literals, read for what they show:
- a decimal example (
3.14,0.0) accepts any number, integers included; - an integer example (
42,0) accepts only integers; - a signed zero (
+0) accepts only non-negative integers.
Type names
Each numeric example also has a name, for when you want the type without choosing a value.
TypeScript's string, number, boolean and null, and unions of them, are real runtime
checks too:
function mix(rate: float, count: int, index: unsigned) { // ≡ 3.14, 42, +0 (uint works too)
return rate + count + index
}
function row(s: string, n: number, b: boolean, u: string | null) {
return [s, n, b, u]
}
console.log(mix(0.5, 2, 3)) // 5.5
console.log(isMonadicError(mix(0.5, 2, -3))) // true
console.log(JSON.stringify(row('a', 1.5, true, null))) // ["a",1.5,true,null]
console.log(isMonadicError(row('a', 1, true, 2))) // true
float and number both accept any number, like 3.14; int is 42 and unsigned is
+0. s: string checks exactly what s: '' checks. any, unknown, void and never
stay unconstrained, because that is what they mean. A type name TJS cannot resolve degrades
to "accept anything" with a warning that suggests an example, one of the type names above,
or a Type with a predicate.
Defaults, optional and nullable parameters
function hello(name = 'world') { return `hello, ${name}` } // optional, defaults to 'world'
function opt(name?: '') { return name } // the same as name = ''
function count(n: number = 5) { return n } // checked as number, default 5
function find(id: 0 | null) { return id } // an integer or null
function render(doc = null) { return doc } // ANY value, default null
console.log(hello(), hello('Ada')) // hello, world hello, Ada
console.log(isMonadicError(hello(7))) // true: the default is also the example
console.log(JSON.stringify(opt())) // ""
console.log(count(), count(2.5)) // 5 2.5
console.log(find(null), find(3)) // null 3
console.log(render({ title: 'x' }).title) // x
Unlike TypeScript, x?: '' does not leave x undefined: it makes x optional and
defaults it to the example.
name: Type = default is TypeScript's spelling, and TJS accepts it: the argument is
checked against what comes after the colon, and the default is used when the argument is
missing. A default that fails its own check (n: 0 = 'x') does not transpile. You need
this spelling when the check is narrower than the default alone: mode = 'grant' accepts
any string, while mode: 'grant' | 'deny' = 'grant' accepts only those two (see
Literal unions).
A bare null or undefined default means "any value, defaulting to this". To accept
only null, write doc: null.
Object shapes and dictionary defaults
An object example is a shape: every member it shows is required and checked.
function createUser(user: { name: '', age: 0 }) { return user.name }
console.log(createUser({ name: 'Ada', age: 36 })) // Ada
console.log(isMonadicError(createUser({ name: 'Ada' }))) // true: age is missing
An object-literal default is different: it is a dictionary. Each member has its own default, a partial argument is merged with them, members are checked, and extra keys pass through:
function place(args = { x: 0, y: 0 }) { return args }
console.log(JSON.stringify(place({ x: 5 }))) // {"x":5,"y":0}
console.log(JSON.stringify(place({ x: 1, z: 9 }))) // {"x":1,"z":9,"y":0}
console.log(isMonadicError(place({ x: 's' }))) // true
This is the one place where the same source means something different in TJS and in
JavaScript, which treats the object as one atomic default. For JavaScript's behaviour on a
single parameter, write args = LegacyDefault({ x: 0, y: 0 }). The full rules are in
Dictionary Defaults. A typed default (p: Point = { x: 1, y: 2 })
is not a dictionary: a value passed for it must be a whole Point.
Arrays and rest parameters
An array example gives the item type, and items are checked, not only arrayness. The TypeScript suffix spelling works too:
function first(xs: [0]) { return xs[0] } // an array of integers
function total(xs: number[]) { return xs.length } // rewritten to [0.0]
function grid(rows: string[][]) { return rows.length }
function sum(...nums: [0]) { return nums.reduce((a, b) => a + b, 0) }
console.log(first([7, 8]), total([1.5, 2]), grid([['a']])) // 7 2 1
console.log(isMonadicError(total(['x']))) // true
console.log(sum(1, 2, 3), isMonadicError(sum(1, 'x'))) // 6 true
int[] accepts only integers; number[] accepts any numbers. A mixed example
(...args: ['', 0, true]) is not a tuple: each item may be any of those types. A rest
parameter cannot have a default (...xs: number[] = [1] is an error), because it is always
bound, to [] when nothing is passed.
Literal unions
A union whose members are all literals of the same type is a closed set of values. A union of literals of different types is a union of their types:
'on' | 'off'accepts only'on'and'off';0 | 1accepts only0and1;0 | ''accepts any integer or any string;0 | nullaccepts any integer, ornull.
function setMode(m: 'on' | 'off') { return m }
function either(x: 0 | '') { return x }
console.log(setMode('on')) // on
console.log(isMonadicError(setMode('maybe'))) // true
console.log(setMode(Object('on')) == 'on') // true: membership is ==
console.log(either(1), either('s')) // 1 s
console.log(isMonadicError(either(true))) // true
Membership uses ==, so a boxed primitive is a member, +0 | +1 is the same set as 0 | 1,
and 1 | 1.0 has one member. Use an Enum when the set wants a name, a description and
reverse lookup (see Declarations).
Return annotations
A return annotation is an example too, and an example of a result is a worked
example. During transpilation (by tjs check, tjs test, a build, or the tjs() API)
the function is called with its parameter examples, and the result must equal the
annotation: deep equality, not "the same type". That check is the function's signature
test. Three spellings say how much checking you want:
// `:` a worked example: add(2, 3) runs during transpilation, and must be 5
function add(a: 2, b: 3): 5 { return a + b }
// `:!` the return TYPE only: nothing runs, the result is not checked
function getUser(id: 0):! { name: '', age: 0 } {
return { name: 'Alice', age: 30 }
}
// `:?` the worked example AND a runtime check of every result
function half(n: 4):? 2 { return n / 2 }
console.log(add(2, 3), getUser(1).name, half(4)) // 5 Alice 2
console.log(isMonadicError(half(3))) // true: 1.5 is not an integer
function add(a: 0, b: 0): 0 passes only because 0 + 0 is 0. The same function
annotated : 0 with examples 2 and 3 does not transpile:
function add(a: 2, b: 3): 0 { return a + b } // add(2, 3) is 5, not 0
Running your code during transpilation raises fair questions. A signature test whose call
throws (because it needs a network, a host object or an import the transpiler cannot load)
is inconclusive, not a failure; see Test blocks. For a function with
side effects, or a result that would be awkward to write out, use :!, which runs nothing.
Converted TypeScript uses :! for every : string-style return type.
With a literal-union parameter, the signature test calls the function with the union's
first member, so function pick(x: 'yes' | 'no'): 'yes' passes and : '' would fail.
An async function's return example is never run as a test; with :? its resolved value
is checked (see Async functions).
Safety markers
A ! at the start of the parameter list turns validation off for that function:
function fastAdd(! a: 0, b: 0) { return a + b }
console.log(fastAdd(3, 4)) // 7
console.log(fastAdd('a', 'b')) // ab: nothing was checked
Use it on hot internal functions whose inputs were checked at the boundary, never on
functions that receive outside data. It disables every check in the function; to relax one
parameter, give it a looser type (x: any).
A ? in the same position (function f(? a: 0)) is accepted for compatibility and has no
effect: parameters are already checked by default, and in a file that turns checking off
with safety none (below) a ? does not turn it back on.
The safety directive
The first line of a file may set how much it checks at runtime:
safety none // no validation; the signature metadata is still emitted
safety inputs // validate function inputs (the default)
safety all // accepted; checks the same as `safety inputs`
safety none is for code whose callers are already checked, and it is what converted
TypeScript defaults to. The metadata it keeps is the signature information TJS attaches to
each function (its __tjs property), which docs and tools read. safety all adds nothing
today: a result is checked at runtime only when its annotation says :?.
safety changes how much is checked, never what the code means, which is why it is not a
mode (see The rules of .tjs).
The rules of .tjs
The file extension is the gate, the way ES modules made "use strict" implicit.
JavaScript source, in its own dialect (a .js file, or converted TypeScript), is always
accepted as JavaScript. A .tjs file opts into the stricter rules below, unconditionally.
| rule | what .tjs does |
|---|---|
| equality | ==/!= do not coerce, unwrap boxed primitives, and keep null == undefined |
typeof |
typeof null is 'null' |
| truthiness | a boxed false (Object(false)) is falsy |
| statements | a line starting with (, [ or ` begins a new statement (see below) |
| classes | called without new; new on a class declared in the file is an error |
Date |
new Date is not allowed; use LegacyDate(x), or Timestamp from tjs-lang |
var |
not allowed; use const or let |
eval() |
not allowed; Eval() is the sandboxed form (new Function is a warning) |
| object-literal parameter defaults | dictionaries: per-member defaults, merged on a partial argument, validated |
a first bare assignment to an undeclared Capital |
becomes const |
Timestamp is TJS's date type (see Built-in Types), and Eval() runs
code in a sandbox (see Safe Eval). Date.now() is allowed, because it
returns a number; the compiler suggests Timestamp.now() or performance.now().
There is no per-file switch for these rules. Older versions had nine mode directives that turned rules on one at a time; they were removed, and writing one now is an error that names the replacement.
Statements and newlines
Semicolons are allowed, and most lines end where JavaScript would end them. The difference
is a line that starts with (, [ or a backtick. JavaScript joins such a line to the one
before it (so foo followed by a line (() => {})() calls foo); TJS starts a new
statement there, and warns:
const total = [1, 2, 3]
.map((x) => x * 2) // a line starting with . continues
.reduce((a, b) => a + b)
const inRange = total > 10 && // so does a line after a trailing operator
total < 100
console.log(total, inRange) // 12 true
A line still continues when the line before it ends with an operator, an opening bracket or
a comma. To continue onto a line that starts with ( or [, end the previous line with
the operator or bracket, or put both on one line.
Named escapes
A whole-file opt-out would also silence the next, accidental use, so each exception marks one site, with a name you can grep for:
| you need | write |
|---|---|
a real Date |
LegacyDate(x) |
JavaScript's == / != |
DangerousLegacyEquals(a, b) / DangerousLegacyNot(a, b) |
JavaScript's typeof (null is object) |
LegacyTypeOf(x) |
| JavaScript's atomic parameter default | args = LegacyDefault({ x: 0, y: 0 }) |
any of the above, from a .ts file |
nothing: conversion writes the named escape for you |
console.log(0 == '', DangerousLegacyEquals(0, '')) // false true
console.log(typeof null, LegacyTypeOf(null)) // null object
console.log(LegacyDate(0).toISOString()) // 1970-01-01T00:00:00.000Z
function f(args = { x: 0, y: 0 }) { return args }
function g(args = LegacyDefault({ x: 0, y: 0 })) { return args }
console.log(JSON.stringify(f({ x: 5 })), JSON.stringify(g({ x: 5 }))) // {"x":5,"y":0} {"x":5}
The names are long on purpose. React's dangerouslySetInnerHTML is the model: an escape
should cost a moment's thought and stand out in review, so there are no short aliases.
var and eval have no escape at all, the way strict mode removed with.
LegacyExactly(a, b) and LegacyNotExactly(a, b) also exist. They are exactly === and
!==, which TJS does not change, so you never need them.
LegacyDefault affects one parameter. Marking the whole function with ! also keeps
JavaScript's atomic default, but it turns off all of that function's validation too.
unsafe <expression> is deprecated. The prefix (unsafe new Date(ts)) was the older
spelling of a site escape. It still parses, with a deprecation warning, and will be removed.
Write LegacyDate(ts) for a Date; for a class declared in the file, drop the new.
Converted TypeScript
Converting TypeScript (fromTS, tjs convert) produces TJS source whose first line marks
where it came from:
/* tjs <- when.ts */
function when(ts: number) {
return LegacyDate(ts); // the TypeScript said: new Date(ts)
}
The annotation means "this file has JavaScript's semantics", which keeps every TypeScript
program working as it did. The converter also writes each place the code relies on
JavaScript's behaviour as a named escape: new Date(x) becomes LegacyDate(x), a coercing
v == 0 becomes DangerousLegacyEquals(v, 0), a typeof v === 'object' that could meet
null becomes LegacyTypeOf(v) === 'object', and an object-literal default becomes
LegacyDefault({ … }).
The two work together. The escapes mean the same thing under JavaScript's rules and TJS's,
so when you are ready, graduate() removes the annotation (which switches the file to the
.tjs rules) and the code still behaves the same. The one thing that cannot be preserved is
eval or new Function: then graduate() refuses, its result's refused field says why,
and the code comes back unchanged.
Dialect: TjsCompat and TjsStrict
Two directives answer a different question from the rules: which language is this file written in? Normally the extension answers it and you write neither.
TjsCompat // JavaScript semantics: ==, typeof, Date, var, defaults as in JS
TjsStrict // full TJS, for TS-originated source (where the default is JavaScript semantics)
TjsCompat turns the rules in the table off, but it does not turn validation off:
parameters are still checked. Add safety none as well if you want neither. TjsStrict
turns every rule on, restores input validation in converted code (which defaults to
safety none), and makes a Type predicate that cannot be verified (see
Declarations) an error instead of a warning.
Plain JavaScript (the tjs() API's dialect: 'js' option), TS-originated code and
AJS, TJS's sandboxed agent language, get JavaScript semantics by default, so every
JavaScript program means what it meant (see Design Principles).
Declarations
TJS declarations are types that survive to runtime. Each one is a value you can call to check a candidate. They are covered in full in Declarations; this is the syntax at a glance:
Type Name 'Alice' // from an example
Type User {
description: 'a user'
example: { name: '', age: 0 }
}
Type Even {
example: 2
predicate(x) { return x % 2 === 0 }
}
Enum Status 'request status' { // the string is the description
Pending = 'pending'
Done = 'done'
}
Union Direction 'a direction' 'north' | 'south' // description, then the members
Type Box<T> {
example: { value: T } // T is checked at the value slot
}
FunctionPredicate Formatter {
params: { input: '' }
returns: ''
}
function unbox(b: Box<int>) { return b.value }
console.log(Name('Ada'), User({ name: 'Ada', age: 36 }), Even(3)) // true true false
console.log(Status('done'), Status.Done, Direction('east')) // true done false
console.log(unbox({ value: 1 })) // 1
console.log(isMonadicError(unbox({ value: 1.5 }))) // true
console.log(Formatter((s) => s), Formatter(42)) // true false
A predicate can also be written without a parameter, as predicate { return Even % 2 === 0 }:
inside it, the type's own name stands for the value being checked.
A predicate that is pure and synchronous (no loops, await, new, or effectful calls; use
every, some, map or recursion) is verified during transpilation and compiled to a
check with a step budget (fuel), so a hostile input cannot hang validation; recursion is
bounded by the same budget. One that cannot be verified still works, as a plain function.
Type Box<T> is a shape whose example mentions T; you fill T in a signature
(b: Box<int>). A Generic is a check that takes other checks as arguments when it runs:
Boxed(0) builds the check for a T whose example is 0, and the predicate receives that
check as T. A Generic can carry a declaration { … } block of TypeScript that is copied
into the generated .d.ts and removed from the JavaScript:
Generic Boxed<T> {
predicate(x, T) { return typeof x === 'object' && T(x.value) }
declaration {
value: T
path: string
}
}
console.log(Boxed(0)({ value: 1 }), Boxed(0)({ value: 'a' }), Boxed(0)(null)) // true false false
typeof x === 'object' is enough here, because in TJS typeof null is 'null'.
FunctionPredicate checks only that a value is a function. Whether a function honours
its signature is something you learn by calling it, so params and returns are a
documented contract for tools, docs and .d.ts output; Declarations has
the details.
Equality, typeof and truthiness
| operator | meaning | example |
|---|---|---|
== |
equality: no coercion, unwraps boxed primitives | Object('x') == 'x' is true |
!= |
its negation | 0 != '' is true |
=== |
identity, unchanged from JavaScript | obj === obj is true |
!== |
not identical | {a:1} !== {a:1} is true |
Is(a, b) |
deep structural equality | Is({a:1}, {a:1}) is true |
IsNot(a, b) |
its negation | IsNot([1,2], [2,1]) is true |
console.log(Object('foo') == 'foo') // true: unwraps
console.log(Object(false) == false) // true
console.log(null == undefined) // true: kept from JavaScript
console.log(0 == '', false == []) // false false: no coercion
console.log(NaN == NaN, NaN === NaN) // true false: === is JavaScript's
const a = { x: 1 }
const b = { x: 1 }
console.log(a == b) // false: objects compare by reference
console.log(Is(a, b)) // true: structure
console.log(Is(new Set([1, 2]), new Set([2, 1]))) // true: Sets ignore order
console.log(IsNot([1, 2], [2, 1])) // true
console.log(typeof null) // null
console.log(!!Object(false)) // false
console.log(Object(false) ? 'a' : 'b') // b
== stays fast because it is not structural; deep comparison is O(n), so you ask for it
by name with Is. Is lets objects define their own equality: a [tjsEquals] method
(Symbol.for('tjs.equals'), exported from tjs-lang/runtime) is used first, then an
.Equals method, then the structural comparison. == never calls these hooks.
a Is b and a IsNot b also work as infix operators; the transpiler rewrites them to the
calls. The call form is the recommended spelling, because it reads the same wherever it
appears.
typeof is unchanged except for null. Every truthiness context (if, !, &&, ||,
?:, loops) unwraps a boxed primitive first; && and || still return one of their
operands, and ?? is untouched because it tests for null/undefined, not truthiness.
LegacyTypeOf restores JavaScript's typeof; boxed truthiness has no escape, because
nothing correct depends on it. JS Footguns That TJS Quietly Fixes has the
longer list.
Control flow: given
given is TJS's dispatch statement. It replaces switch's three hazards: arms do not fall
through, each arm has its own scope, and matching uses TJS's ==.
function describe(kind: '') {
given kind {
'cat', 'dog' { return 'pet' } // several values on one arm
'lion', 'tiger' { return 'wild' }
} else {
return 'unknown'
}
}
function label(level: 0) {
let text = 'normal'
given level {
1 { text = 'low' } // an arm that does work, then continues
3, 4 { text = 'high' }
} // no else: 2 matches nothing
return text
}
console.log(describe('dog'), describe('tiger'), describe('emu')) // pet wild unknown
console.log(label(1), label(2), label(4)) // low normal high
Arm values can be any expression, and a given can be nested inside an arm. Arms compare
values; there are no ranges, guards or destructuring, so compute the value to compare (for
example Math.floor(code / 100)) or use if.
switch still works, with C's semantics exactly, and compiles with a warning that points
at given. switch is legal JavaScript that the .tjs rules do not ban, so TJS keeps
its meaning rather than quietly changing it, and gives the fixed construct a new name.
given is TJS syntax: it is not valid in .js files or in AJS. More examples are in
Control Flow: given, and the reasons for a new keyword are in
Case study: fixing switch.
Test blocks
A test block states what the code does, and it runs during transpilation:
function slugify(title: 'Hello World'): 'hello-world' {
return title.trim().toLowerCase().split(' ').join('-')
}
test 'slugify joins words with dashes' {
expect(slugify(' Two Words ')).toBe('two-words')
expect(slugify('A B C').split('-')).toEqual(['a', 'b', 'c'])
}
test 'slugify refuses a number' {
expect(isMonadicError(slugify(42))).toBe(true)
}
test {
// a test with no name, using assert
assert(slugify('x') == 'x', 'a single word is unchanged')
}
console.log(slugify('Syntax Reference')) // syntax-reference
When they run. The tjs(source, { runTests }) transpiler API runs the tests and the
signature tests (every : return annotation) and throws if one fails, so the tjs CLI's
check and test commands and a build all stop on a failing test. Tests are removed from
the emitted JavaScript. Pass runTests: false to skip them, or runTests: 'report' to
collect the results in testResults without throwing. The Bun plugin does not run them on
import unless TJS_RUN_TESTS=1 is set. A test body may use await.
Matchers. expect(actual) supports toBe, toEqual, toContain (substring for
strings, membership for arrays), toThrow(message?) (on a function),
toBeTruthy, toBeFalsy, toBeNull, toBeUndefined, toBeGreaterThan,
toBeLessThan and toBeNaN. toBe and toEqual both compare by deep equality. There is
no .not; write expect(x == y).toBe(false). assert(condition, message?) is also
available; the message is what a failure reports.
Inconclusive is not failure. A signature test whose call throws, or a test that needs
something the transpiler cannot load (an import, a host object), is inconclusive and does
not stop the build. A test that runs and is wrong does. With runTests: 'report', the
inconclusive ones are in testResults, marked inconclusive: true.
A mock { … } block holds setup code for the tests. What it declares is visible inside
every test:
function greet(name: 'Ada') { return `Hello, ${name}` }
mock {
const visitors = ['Ada', 'Grace']
}
test 'greets every visitor' {
expect(visitors.map((v) => greet(v))).toEqual(['Hello, Ada', 'Hello, Grace'])
}
console.log(greet('Grace')) // Hello, Grace
Do not rely on a mock's state being reset between tests: when a test changes it, a later test may see the change.
In a TypeScript file, the same blocks can be written inside a comment
(/*test 'name' { … }*/) so tsc ignores them.
Errors
A type error is a value, not an exception. A call with a bad argument returns a
MonadicError (a subclass of Error), and a typed function that receives one returns it
unchanged without running its body, so an error travels to the end of a chain by itself:
function clean(name: ''): '' { return name.trim() }
function shout(text: ''): '' { return text.toUpperCase() }
const ok = shout(clean(' ada '))
const err = shout(clean(42))
console.log(ok) // ADA
console.log(isMonadicError(err)) // true
console.log(err.expected, err.actual) // string number
console.log(err.path.endsWith('clean.name')) // true: it names where it started
isMonadicError(x) is the check to use; it works across module boundaries. An error
carries path (file, line, function and parameter), expected, actual and a message
built from them. Using an error in a plain expression (err.length, `${err}`) does not
propagate it, so check before doing arithmetic or string work on a value that might be one.
try without catch
A try block with no catch turns an exception into a returned MonadicError:
function parse(json: '{}'):! {} {
try {
return JSON.parse(json)
}
}
console.log(parse('{"ok":true}').ok) // true
console.log(isMonadicError(parse('nope'))) // true, and nothing was thrown
A try with a catch or finally is JavaScript's. A throw, or an exception from a
library, behaves as it does in JavaScript; TJS changes only how type errors are reported.
Bang access: x!.prop
x!.prop reads a property, or returns a MonadicError when x is null or undefined.
Chains short-circuit at the first failure:
const user = { profile: null }
const name = user!.profile!.name
console.log(isMonadicError(name)) // true
console.log(name.message) // Expected non-null for 'bang.name', got null
const found = { profile: { name: 'Ada' } }
console.log(found!.profile!.name) // Ada
The path bang.name names the property that could not be read: profile was null, so
.name failed. Optional chaining (?.) returns undefined silently; !. produces an
error you can track.
Every type error is also kept by the runtime. __tjs is the runtime's global object:
__tjs.errors() lists recent type errors, and __tjs.records() lists everything the
runtime noted, warnings included. So an ignored error can still be found later; see
Flight Recorder and Error Handling.
Classes
A class is called without new, and its methods and constructors validate their
parameters like functions:
class Box {
constructor(v: 0) { this.v = v }
scale(k: 0) { return this.v * k }
}
console.log(Box(2).scale(3)) // 6
console.log(Box(2) instanceof Box) // true
console.log(isMonadicError(Box('two'))) // true
console.log(isMonadicError(Box(2).scale('x'))) // true
const buf = new Float32Array(4) // built-ins still need new
console.log(buf.length, new Set([1, 2]).size) // 4 2
new on a class declared in the same file is an error. Box(2) and new Box(2) build
the same object, so the keyword adds nothing. Built-in constructors are untouched, because
for them new matters: new Float32Array(4) is required. The exceptions are new String,
new Number and new Boolean, which are refused: they build an object wrapper, almost
always by mistake for String(x) and friends. Object(x) makes one deliberately.
Old-style function constructors are untouched too.
class Point {
constructor(x: 0.0, y: 0.0) { this.x = x; this.y = y }
}
const p = new Point(1, 2) // error: call it, Point(1, 2)
Setters do not validate, because a setter's return value is discarded and a refusal would silently drop the write.
Watch platform callbacks. When the caller ignores the return value, a refused call does
nothing visible (it is still recorded in __tjs.records()). The browser calls
attributeChangedCallback(name, oldValue, newValue) with oldValue set to null the first
time, so old = '' refuses that call. Write the default the platform actually passes; a
bare null default accepts any value:
attributeChangedCallback(name = '', old = null, value = null) { … }
Polymorphic constructors
A class can declare several constructors; the call picks one by its arguments:
class Point {
constructor(x: 0.0, y: 0.0) {
this.x = x
this.y = y
}
constructor(coords: { x: 0.0, y: 0.0 }) {
this.x = coords.x
this.y = coords.y
}
get length() { return Math.hypot(this.x, this.y) }
}
console.log(Point(3, 4).length) // 5
console.log(Point({ x: 6, y: 8 }).length) // 10
console.log(Point({ x: 6, y: 8 }) instanceof Point) // true
The first constructor is the real one; the others become factories that produce instances of the same class. See Polymorphic Constructors.
Polymorphic functions
Several declarations with the same name are merged into one function that dispatches on its arguments:
function area(radius: 3.14) { return Math.PI * radius * radius }
function area(w: 0.0, h: 0.0) { return w * h }
console.log(area(1)) // 3.141592653589793
console.log(area(3, 4)) // 12
console.log(isMonadicError(area('x'))) // true: no variant matches
Dispatch is by arity first, then by how specific the types are, then by declaration order. Two variants with the same types at the same arity do not transpile. See Polymorphic Functions.
extend: local class extensions
An extend block adds methods to a built-in type without touching its prototype, and they
apply only where the receiver is written as a literal. It suits helpers you call on
literals in your own file, such as string and array literals in a template or a table of
data:
extend String {
shout() { return this.toUpperCase() + '!' }
}
extend Array {
last() { return this[this.length - 1] }
}
console.log('hello'.shout()) // HELLO!
console.log([3, 1, 4].last()) // 4
console.log(typeof String.prototype.shout) // undefined: nothing was patched
A call is rewritten during transpilation to an ordinary call with the receiver as this.
The receivers that are rewritten are a string or template literal, an array literal, and a
number literal with a decimal point (7.5). A call on anything else, a variable or an
integer such as (7), is left as written and fails when it runs (xs.last is not a function), because no prototype was changed. Use an ordinary function for values you pass
around. The full receiver table is in Local Class Extensions.
Methods must use method syntax, since they need this; an arrow-function member is left
out. Several extend blocks for one type merge. Extension methods do not validate their
parameters. Extensions are file-local: an importing module never sees them.
const!: compile-time immutability
const! declares a binding whose contents cannot be changed. The check happens during
transpilation and costs nothing at runtime: the output is a plain const.
const! config = { debug: false, port: 8080 }
const! items = [1, 2, 3]
console.log(config.port) // 8080: reads are fine
console.log(items.map((x) => x * 2).join()) // 2,4,6: non-mutating methods are fine
const! items = [1, 2, 3]
items.push(4) // does not transpile
It catches property assignment, compound assignment (+=), ++/--, delete, and the
mutating array methods (push, pop, splice, shift, unshift, sort, reverse,
fill) made directly through the name. It is shallow and it does not follow the value: a
nested write (config.db.port = 1), a second binding (const c = config) and a function
that receives the value can all still change it.
Bare assignments
In .tjs, the first assignment to an undeclared name that starts with a capital letter
declares it as const:
Settings = { debug: true } // becomes: const Settings = { debug: true }
console.log(Settings.debug) // true
A second Settings = … in the same file does not transpile ("Identifier 'Settings' has
already been declared"), so declare let Settings yourself if it needs to change. A name
that is already declared is left alone. JavaScript, converted TypeScript and AJS are
unaffected.
Doc comments: /# … #/
TJS's own doc comment is multi-line and nestable, and it can quote any syntax without escaping. Its contents are markdown:
/#
## Comments in JavaScript
A block comment runs from /* to */, and that is fine to write here.
A nested doc comment works too: /# inner #/ with no escaping.
#/
function identity(a: 0): 0 { return a }
console.log(identity(7)) // 7
A JavaScript block comment cannot contain */, so it cannot document comment syntax.
/*# … */ still works as an ordinary comment, and is the convention in .ts files, where
/# … #/ does not parse. JSDoc /** … */ comments are read as docs too.
The newline after /# is required, and not as a style rule: a one-line /# x #/ is a valid
JavaScript regular expression, so it stays one. Doc comments are blanked before any other
pass reads the source, so a test, wasm function, Type or marker written inside one is
inert. AJS has them too.
Modules and imports
A .tjs file is an ES module. import and export mean what they mean in JavaScript, and a
function keeps its checks and its __tjs metadata across the boundary, so a caller in
another file cannot skip them:
// mymath.tjs
export function add(a: 0, b: 0): 0 { return a + b }
// app.tjs
import { isMonadicError } from 'tjs-lang/runtime'
import { add } from './mymath.tjs'
console.log(add(2, 3)) // 5
console.log(isMonadicError(add('2', 3))) // true: add checks its own inputs
app.tjs imports isMonadicError because it declares no typed function of its own, so its
emitted code carries no runtime that would provide it.
In Bun, preload tjs-lang/bun-plugin (in bunfig.toml) to import .tjs files directly;
for Bun.build, use tjsPlugin from tjs-lang/bun-build-plugin. Otherwise import the
emitted .js. A test that depends on an import the transpiler cannot load is inconclusive,
not failed. The playground resolves saved modules by name; see
Local Module Imports.
Async functions
An async function's parameters are checked when it is called, as for any function. A type
error does not reject the promise: the promise resolves to the MonadicError. The
return example is never run as a test. With : or :! the resolved value is not checked;
with :? it is, and a wrong result resolves to a MonadicError:
async function loadScore(player: 'ada'): 30 {
await new Promise((resolve) => setTimeout(resolve, 10))
return player.length * 10
}
async function checkedScore(player: 'ada'):? 30 {
return player // a string, not an integer
}
console.log(await loadScore('ada')) // 30
const bad = await loadScore(42) // resolves, does not reject
console.log(isMonadicError(bad)) // true
console.log(isMonadicError(await checkedScore('ada'))) // true: :? checked the result
So check the awaited value with isMonadicError() where you use it, and keep the return
example truthful, since docs and metadata show it. See Async Functions.
WASM
TJS compiles WebAssembly during transpilation and embeds it in the output. There are two
forms: a wasm function at module scope, which other files can import, and a wasm { }
block inside an ordinary function, with an optional fallback { } for runtimes that cannot
run WebAssembly:
wasm function addInts(a: i32, b: i32): f64 {
return a + b
}
function double(! arr: Float32Array, len: 0) {
wasm {
for (let i = 0; i < len; i++) {
arr[i] = arr[i] * 2.0
}
} fallback {
for (let i = 0; i < len; i++) arr[i] *= 2
}
return arr
}
console.log(addInts(2, 3)) // 5
console.log(double(new Float32Array([1, 2]), 2).join()) // 2,4
wasm function parameters use WebAssembly type names (i32, f64, Float32Array, …),
not examples. Today a wasm function returns f64 or nothing; : i32 and the other return
types are accepted, but the result still comes back as an f64.
A wasm { } block needs arr: Float32Array to know that arr is a typed array. TJS has
no runtime check for typed-array names, so in an ordinary function that parameter is
unchecked and transpiles with a warning. double is marked ! to say so, and ! turns off
the len check as well; leave it off to keep len checked and accept the warning.
export wasm function makes a library that other .tjs files compose into their own
module. Free variables in a wasm { } block become its parameters. A block that cannot
compile does not transpile. The f32x4_* SIMD intrinsics and wasmBuffer() (zero-copy
typed arrays in WebAssembly memory) are covered, with the supported subset and the current
limits, in WASM in TJS.
@tjs annotations in TypeScript
A TypeScript file can carry /* @tjs … */ comments that fromTS uses to enrich its TJS
output. tsc sees ordinary comments.
/* @tjs TjsStrict */ // at the top: convert to full TJS
/* @tjs example: { name: 'Alice', age: 30 } */ // the example for the next interface or type
/* @tjs predicate(x) { return x.age >= 0 } */ // a predicate for the next interface or type
/* @tjs declaration { value: T } */ // the .d.ts declaration for the next generic
/* @tjs-skip */ // do not convert the next interface or type
/* @tjs-unsafe */ // before an expression: see below
example, predicate, declaration and @tjs-skip apply to the interface or type
declaration that follows them. TjsStrict is written at the top of the converted output
(see Dialect).
/* @tjs-unsafe */ converts to the deprecated unsafe prefix, so the converted file warns.
You rarely need it: conversion already writes LegacyDate(x) for a new Date(x) and the
other named escapes listed under Converted TypeScript.