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:

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:

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.