TJS for JavaScript Programmers

Everything you already know, plus the things you always wished JavaScript had.


The One-Sentence Pitch

TJS is JavaScript with types that actually exist at runtime, equality that actually works, and errors that don't crash your program.


What Stays the Same

TJS is a superset of JavaScript. All of this works exactly as you expect:

const x = 42
let name = 'Alice'
const items = [1, 2, 3].map((n) => n * 2)
const user = { name: 'Bob', age: 30 }
const { name: userName, age } = user
const merged = { ...defaults, ...overrides }
const greeting = `Hello, ${name}!`

for (const item of items) {
  console.log(item)
}
while (condition) {
  /* ... */
}
if (x > 0) {
  /* ... */
} else {
  /* ... */
}

try {
  riskyThing()
} catch (e) {
  handleError(e)
}

class Dog {
  #name
  constructor(name) {
    this.#name = name
  }
  get name() {
    return this.#name
  }
}

import { something } from './module.js'
export function myFunction() {
  /* ... */
}

The syntax above is all valid TJS. But note the next section: when you transpile native TJS, TJS also improves some runtime behaviour (footgun-free ==, honest truthiness, …). Those improvements are exactly the point of writing TJS — but they would change the meaning of existing JavaScript.

So TJS gates them on a dialect:

import { tjs } from 'tjs-lang/lang'

tjs(jsSource) //                    → native TJS (improvements ON — the default)
tjs(jsSource, { dialect: 'js' }) // → plain JS  (semantics preserved, untouched)

For file-based tools, the extension is the dialect — .js/.mjs ⇒ dialect: 'js', .tjs ⇒ native. Use the canonical helper so every tool agrees:

import { tjs, dialectForFilename } from 'tjs-lang/lang'
tjs(source, { dialect: dialectForFilename(filename) })

Handling .ts too (a doc system, a bundler plugin)? Route the TS path through fromTS, imported from its own entry so the TypeScript compiler only loads when you actually transpile TS — tjs-lang/lang itself stays TS-free:

import { tjs, sourceKindForFilename } from 'tjs-lang/lang'
import { fromTS } from 'tjs-lang/lang/from-ts' // TS compiler — only on the ts path

const kind = sourceKindForFilename(filename) // 'js' | 'ts' | 'tjs'
const code =
  kind === 'ts'
    ? tjs(fromTS(source, { emitTJS: true }).code).code
    : tjs(source, { dialect: kind }).code

With dialect: 'js' (or a .js file), if you don't use any TJS features your code is just JavaScript — same behaviour, no lock-in.

This extends to advanced patterns too: Proxies, WeakMap/WeakSet, Symbols, generators, async iterators, Object.defineProperty — all work identically. TJS adds type checks at function boundaries; it doesn't wrap or intercept any JS runtime behavior.


What's Different

1. Types Are Example Values

In JavaScript, there are no types. In TJS, you annotate parameters with an example of a valid value:

// JavaScript
function greet(name) {
  return `Hello, ${name}!`
}

// TJS - name is required and must be a string (like 'World')
function greet(name: 'World'): '' {
  return `Hello, ${name}!`
}

The : 'World' means "required, must be a string, here's an example." The : '' means "returns a string." These aren't abstract type annotations -- they're concrete values the system can use for testing, documentation, and validation.

You Write TJS Infers
name: 'Alice' Required string
count: 42 Required integer
rate: 3.14 Required number (float)
age: +20 Required non-negative integer
flag: true Required boolean
items: [0] Required integer[]
values: [0.0] Required number[]
user: { n: '' } Required object
id: 0 || null integer or null

All of these are valid JavaScript expressions. 42 vs 42.0 vs +42 are all legal JS -- TJS leverages this to distinguish integer, float, and non-negative integer types at the syntax level.

Note: || null means the value accepts the base type or null, but not undefined. TJS treats null and undefined as distinct types (typeOf(null) === 'null', typeOf(undefined) === 'undefined').

Optional parameters use = (just like JS default values):

function greet(name = 'World') {
  /* ... */
} // optional, defaults to 'World'
function retry(count = 3) {
  /* ... */
} // optional, defaults to 3 (integer)

The difference: : value means required. = value means optional with a default. In plain JS, both would be written as = value.

2. Numeric Type Narrowing

TJS distinguishes three numeric types using valid JavaScript syntax:

function process(
  rate: 3.14,    // number (float) -- has a decimal point
  count: 42,     // integer -- whole number, no decimal
  index: +0      // non-negative integer -- prefixed with +
) { /* ... */ }
You Write Type Inferred Validates
3.14 number (float) Any number
0.0 number (float) Any number
42 integer Number.isInteger(x)
0 integer Number.isInteger(x)
+20 non-negative integer Number.isInteger(x) && x >= 0
+0 non-negative integer Number.isInteger(x) && x >= 0
-5 integer Number.isInteger(x)
-3.5 number (float) Any number

These are all valid JavaScript expressions -- TJS just reads the syntax more carefully than JS does. At runtime, passing 3.14 to a parameter typed as integer returns a monadic error.

3. Return Type Annotations

TJS uses : for return types (same as TypeScript):

// Returns an integer
function add(a: 0, b: 0): 0 {
  return a + b
}

// Returns an object with specific shape
function getUser(id: 0): { name: '', age: 0 } {
  return { name: 'Alice', age: 30 }
}

The return type example doubles as an automatic test. When you write : 0, TJS will call add(0, 0) at transpile time and verify the result is a number.

4. Equality That Works

JavaScript's == is notoriously broken (type coercion). TJS fixes == to be a footgun-free === — and adds Is for deep structural comparison:

// JavaScript
'5' == 5                    // true  (coercion — footgun!)
0 == false                  // true  (coercion — footgun!)
new Boolean(false) == false // false (boxed primitive — footgun!)

// TJS (footgun-free == is on by default)
'5' == 5                    // false (no coercion)
0 == false                  // false (no coercion)
new Boolean(false) == false // true  (unwraps boxed primitives)
null == undefined           // true  (nullish equality)
NaN == NaN                  // true  (JS gets this wrong)

// TJS == is NOT structural — distinct objects are distinct:
[1, 2] == [1, 2]            // false (different references)
{ a: 1 } == { a: 1 }        // false (different references)

// For deep structural comparison, use Is:
[1, 2] Is [1, 2]            // true (same structure)
{ a: 1 } Is { a: 1 }        // true (same structure)

TJS redefines the operators:

Operator JavaScript TJS
== Coercive equality Footgun-free === (no coercion, unwraps boxed, null==undefined); NOT structural — use Is
!= Coercive inequality Negation of the above
=== Strict equality Identity (same ref) — unchanged from JS
!== Strict inequality Not same reference — unchanged from JS

For deep structural comparison, use the explicit forms Is and IsNot:

user Is expectedUser       // structural deep equality
result IsNot errorValue    // structural deep inequality

Classes can define custom equality:

class Point {
  constructor(x: 0, y: 0) { this.x = x; this.y = y }
  Equals(other) { return this.x === other.x && this.y === other.y }
}

Point(1, 2) Is Point(1, 2)  // true, uses .Equals

Note: Is (structural comparison) does not handle circular references. Use == or === for objects that might be circular, or define an .Equals method.

5. Errors Are Values, Not Exceptions

In JavaScript, type errors crash your program. In TJS, they're values:

function double(x: 0): 0 {
  return x * 2
}

double(5) // 10
double('oops') // { $error: true, message: "Expected number for 'double.x', got string" }

No try/catch. No crashes. The caller gets a clear error value they can inspect:

const result = double(input)
if (result?.$error) {
  console.log(result.message) // handle gracefully
} else {
  useResult(result)
}

Errors propagate automatically through function calls. If you pass a monadic error to another TJS function where it does not fit the parameter's type, it passes through without executing:

const a = step1(badInput) // MonadicError
const b = step2(a) // skips execution, returns the same error
const c = step3(b) // skips again -- error flows to the surface

This is sometimes called "railway-oriented programming." A function that declares it takes an error (e: Error, x: any, x: unknown) receives it and runs — that is how a handler works — and a plain Error passed where something else is expected is an ordinary type error.

6. Classes Without new

TJS classes are callable as functions:

// JavaScript
const p = new Point(10, 20)

// TJS - both work, but the clean form is preferred
const p1 = Point(10, 20) // works
const p2 = new Point(10, 20) // also works (linter warns)

This is cleaner and more consistent with functional style. Under the hood, TJS uses a Proxy to auto-construct when you call a class as a function.

7. const for Free

Uppercase identifiers automatically get const:

// TJS
Config = { debug: true, version: '1.0' }
MaxRetries = 3

// Transpiles to
const Config = { debug: true, version: '1.0' }
const MaxRetries = 3

Lowercase identifiers behave normally. This is a native-TJS feature — it's off when you transpile a plain-JS file (dialect: 'js'), so JS keeps its own semantics. It also only applies to the first assignment of an otherwise-undeclared uppercase name: let B = null; … B = 2 (a reassignment) is left alone. Since the first assignment becomes a const, use let Foo = … up front if Foo needs to change later — otherwise a later Foo = … throws.


The Type System

JavaScript has no type system. Libraries like Zod and Ajv bolt one on, but they're separate from your function signatures -- a second source of truth that drifts. TJS's Type() built-in gives you as much narrowing and specificity as you want, in one place, using plain functions you already know how to write.

From Simple to Sophisticated

// Simple -- infer type from an example value
Type Name 'Alice'                            // any string

// Descriptive -- add documentation
Type User {
  description: 'a registered user'
  example: { name: '', age: 0, email: '' }
}

// Constrained -- add a predicate for narrowing
Type PositiveNumber {
  description: 'a number greater than zero'
  example: 1
  predicate(x) { return typeof x === 'number' && x > 0 }
}

// Domain-specific -- real business rules
Type USZipCode {
  description: '5-digit US zip code'
  example: '90210'
  predicate(v) { return typeof v === 'string' && /^\d{5}$/.test(v) }
}

Type Email {
  description: 'email address'
  example: 'user@example.com'
  predicate(v) { return typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) }
}

The key insight: a predicate is just a function that returns true or false. If you can express a constraint as a boolean check, you can make it a type. No schema DSL to learn, no special syntax -- it's JavaScript all the way down.

Types work in function signatures:

function sendWelcome(email: Email, name: Name): '' {
  return `Welcome, ${name}! Confirmation sent to ${email}.`
}

sendWelcome('alice@example.com', 'Alice') // works
sendWelcome('not-an-email', 'Alice') // MonadicError

TJS also ships common types out of the box: TString, TNumber, TBoolean, TInteger, TPositiveInt, TNonEmptyString, TEmail, TUrl, TUuid, Timestamp, LegalDate. No imports from a validation library needed.

Combinators

Compose types from other types:

Type OptionalEmail Nullable(Email)         // Email | null
Type UserIds TArray(TPositiveInt)           // array of positive integers

Unions and Enums

// Union of string literals
Union Status 'task status' 'pending' | 'active' | 'done'

// Enum with named members
Enum Color 'CSS color' {
  Red = 'red'
  Green = 'green'
  Blue = 'blue'
}

// Discriminated union -- like tagged unions or sum types
const Shape = Union('kind', {
  circle: { radius: 0 },
  rectangle: { width: 0, height: 0 }
})

Generics

Runtime-checkable generic types:

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

// Instantiate with a concrete type
const NumberBox = Box(TNumber)
NumberBox.check({ value: 42 })     // true
NumberBox.check({ value: 'nope' }) // false

Runtime Metadata

Every TJS function carries its type information at runtime via __tjs:

function createUser(input: { name: '', age: 0 }): { id: 0 } {
  return { id: 123 }
}

createUser.__tjs
// {
//   params: { input: { type: { kind: 'object', shape: {...} }, required: true } },
//   returns: { type: { kind: 'object', shape: { id: { kind: 'number' } } } }
// }

This metadata enables autocomplete from live objects, automatic documentation generation, and runtime reflection -- things that require build tools and external libraries in vanilla JavaScript.


Safety Levels

You control how much validation TJS applies:

// Per-module (top of file)
safety none     // Metadata only -- no runtime checks (fastest)
safety inputs   // Validate inputs only (default)
safety all      // Validate everything (debug mode)

// Per-function
function fastPath(! x: 0) { /* ... */ }   // Skip validation
function safePath(? x: 0) { /* ... */ }   // Force validation

Use ! (skip validation) only in hot loops where every microsecond counts and the data source is already trusted. In all other cases, the ~1.5x overhead of safety inputs is negligible compared to the bugs it catches.

There Is No unsafe { } Block

unsafe is an expression PREFIX, not a block. To skip validation for a hot path, mark the FUNCTION with !:

function hotLoop(! data: [0]) {
  let total = 0
  for (let i = 0; i < data.length; i++) total += data[i]
  return total
}

Inline Tests

Tests live next to the code they test and run at transpile time:

function isPrime(n: 2): true {
  if (n < 2) return false
  for (let i = 2; i * i <= n; i++) {
    if (n % i === 0) return false
  }
  return true
}

test 'prime detection' {
  expect(isPrime(2)).toBe(true)
  expect(isPrime(4)).toBe(false)
  expect(isPrime(17)).toBe(true)
}

Tests are stripped from production output. They're also generated automatically from return type annotations -- : true means TJS will call isPrime(2) and verify it returns a boolean.


WASM Blocks

For compute-heavy code, drop into WebAssembly:

const add = wasm (a: i32, b: i32): i32 {
  local.get $a
  local.get $b
  i32.add
}

add(1, 2)  // 3, runs as native WASM

WASM is compiled at transpile time and embedded as base64 in the output. No separate .wasm files.

Reusable WASM Functions

For a kernel that other files can import, use a top-level wasm function declaration:

export wasm function dot(a: Float32Array, b: Float32Array, n: i32): f64 {
  // ... SIMD f32x4 implementation ...
}

Other files can import it directly:

import { dot } from './my-lib.tjs'
// `dot` is composed into your file's wasm module at transpile time
// — no JS↔WASM boundary on intra-library calls

The first stdlib built on this is tjs-lang/linalg — SIMD vector kernels ready to use:

import { dot, norm_sq } from 'tjs-lang/linalg'
const cos = dot(a, b, n) / Math.sqrt(norm_sq(a, n) * norm_sq(b, n))

See DOCS-WASM.md for the full story — declaration syntax, the JS-owns-memory model, cross-file composition, and the two distribution forms (composed vs. boundary).


Safe Eval

The killer feature for many use cases. Run untrusted code safely:

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

// One-shot evaluation
const { result } = await Eval({
  code: 'return items.filter(x => x > threshold)',
  context: { items: [1, 5, 10, 15], threshold: 7 },
  fuel: 1000,
})
// result: [10, 15]

// Reusable safe function
const transform = await SafeFunction({
  body: 'return x * multiplier',
  params: ['x'],
  fuel: 500,
})

await transform(21) // { result: 42, fuelUsed: 8 }

What You Give Up

TJS is opinionated. Here's what changes:

JavaScript TJS Why
== (type coercion) == (footgun-free ===) Coercion is a bug factory
Exceptions for type bugs Monadic error values Exceptions escape, values don't
new ClassName() ClassName() preferred Cleaner, more functional
No runtime types __tjs metadata on functions Types should exist at runtime
typeof null === 'object' typeOf(null) === 'null' JS got this wrong in 1995

Nothing is taken away. new still works. === still works. You can write plain JavaScript in a .tjs file and it works. The type-related additions use explicit syntax (: annotations, : return types, Type declarations). Behavioral modes like footgun-free ==, callable classes, and honest typeof are enabled by default in native TJS files. Use TjsCompat at the top of a file to disable all modes for gradual migration or JS interop.


Getting Started

npm install tjs-lang

Try It in the Browser

The playground runs the full TJS compiler in your browser -- no backend needed:

tjs-platform.web.app

CLI

bun src/cli/tjs.ts check file.tjs   # Parse and type check
bun src/cli/tjs.ts run file.tjs     # Transpile and execute
bun src/cli/tjs.ts emit file.tjs    # Output transpiled JS
bun src/cli/tjs.ts test file.tjs    # Run inline tests

From Code

import { tjs } from 'tjs-lang'

const output = tjs`
  function add(a: 0, b: 0): 0 {
    return a + b
  }
`

Runtime Traceability

Every TJS function carries its source identity in __tjs metadata. When validation fails, the error tells you exactly which function and parameter failed, in which source file:

function add(a: 0, b: 0): 0 {
  return a + b
}

add.__tjs.source // "mymodule.tjs:3"
add('oops', 1) // MonadicError { path: "mymodule.tjs:3:add.a", expected: "integer", actual: "string" }

No source maps. No build artifacts. The function knows where it came from.


Learn More