TJS: Typed JavaScript

TJS is a typed superset of JavaScript where types are examples.

function greet(name: 'World', times: 3): '' {
  let result = ''
  let i = 0
  while (i < times) {
    result = result + `Hello, ${name}! `
    i = i + 1
  }
  return result.trim()
}

In this section

Philosophy

TJS takes a different approach to typing than TypeScript:

Aspect TypeScript TJS
Types Abstract declarations Concrete examples
Runtime Erased completely Preserved as metadata
Validation Compile-time only Runtime optional
Learning curve Learn type syntax Use values you know
Error messages "Type 'string' is not assignable to..." "Expected string, got number"

Why Examples?

Consider how you'd explain a function to another developer:

"This function takes a name like 'World' and a count like 3, and returns a greeting string"

That's exactly how TJS works. The example is the type:

// TypeScript
function greet(name: string, times: number): string

// TJS - the example IS the documentation
function greet(name: 'World', times: 3): ''

Core Concepts

1. Types by Example

Instead of abstract type names, use example values:

// Strings
name: ''           // any string
name: 'default'    // string with default value

// Numbers
count: 0           // any number
port: 8080         // number with default

// Booleans
enabled: true      // boolean (default true)
disabled: false    // boolean (default false)

// Arrays
items: ['']        // array of strings
numbers: [0]       // array of numbers
mixed: [0, '']     // tuple: number, string

// Objects
user: { name: '', age: 0 }  // object with shape

// Null/Undefined
nullable: null
optional: undefined

2. Required vs Optional (: vs =)

The colon : means required, equals = means optional:

function createUser(
  name: 'Anonymous', // required string
  email: 'user@example.com', // required string
  age = 0, // optional number (defaults to 0)
  role = 'user' // optional string (defaults to 'user')
): { id: '', name: '', email: '', age: 0, role: '' } {
  return {
    id: crypto.randomUUID(),
    name,
    email,
    age,
    role,
  }
}

// Valid calls:
createUser('Alice', 'alice@example.com')
createUser('Bob', 'bob@example.com', 30)
createUser('Carol', 'carol@example.com', 25, 'admin')

// Invalid - missing required params:
createUser('Dave') // Error: missing required parameter 'email'

3. Return Type Annotation

Use : to declare the return type:

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

function getUser(id: ''): { name: '', email: '' } | null {
  // Returns user object or null
}

4. Union Types

Use | for unions (same as TypeScript):

function parseInput(value: '' | 0 | null): '' {
  if (value === null) return 'null'
  if (typeof value === 'number') return `number: ${value}`
  return `string: ${value}`
}

5. The any Type

When you genuinely don't know the type:

function identity(x: any): any {
  return x
}

Generics from TypeScript become any but preserve metadata:

// TypeScript: function identity<T>(x: T): T
// TJS: any, but __tjs.typeParams captures the generic info
function identity(x: any): any {
  return x
}
// identity.__tjs.typeParams = { T: {} }

6. Type Declarations

Define reusable types with the Type keyword:

// Type with default value (= syntax)
Type Name = 'Alice'
Type Count = 0
Type Age = +18              // positive number

// Type with description and example
Type User {
  description: 'a registered user'
  example: { name: '', age: 0 }
}

// Type with predicate (auto-generates type guard from example)
Type EvenNumber {
  example: 2
  predicate(x) { return x % 2 === 0 }
}

// Complex validation with predicate
Type Email {
  example: 'test@example.com'
  predicate(x) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(x) }
}

Default vs Example:

When example and predicate are provided, the type guard auto-checks the example's shape, then your predicate refines it.

7. Generic Declarations

Define parameterized types with the Generic keyword:

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

// Generic with default type parameter
Generic Container<T, U = ''> {
  description: 'container with label'
  predicate(obj, T, U) {
    return T(obj.item) && U(obj.label)
  }
}

In the predicate, T and U are type-checking functions that validate values against the provided type parameters.

8. Bare Assignments

Uppercase identifiers automatically get const:

// These are equivalent:
Foo = Type('test', 'example')
const Foo = Type('test', 'example')

// Works for any uppercase identifier
MyConfig = { debug: true }
const MyConfig = { debug: true }

Native-TJS only — off for plain JS (dialect: 'js'), TS, and VM code. Fires only on the first assignment of an undeclared uppercase name; a reassignment of an already-declared binding (let B = null; … B = 2) is untouched. Because the first assignment becomes const, declare let Foo if you need Foo to change later.

Runtime Features

Monadic Error Handling

TJS functions propagate errors automatically — when a MonadicError arrives where it does not fit the parameter's type:

// processData(input: { rows: [0] }) — a MonadicError is not { rows: [0] }
const result = processData(maybeError)
// If maybeError is a MonadicError, result is that same error (the body does not run)

// Check for errors
if (isError(result)) {
  console.log(result.message)
}

The rule is decided at the type check, so a function that DECLARES it takes an error gets one: describe(e: Error), log(detail: any) and isErr(x: unknown) run their bodies. A plain Error (from new Error() or a catch) where something else is expected is an ordinary type error, not a pass-through.

The Flight Recorder

Since monadic errors don't throw, they can silently vanish. TJS keeps a bounded ring of what the runtime noticed, so you always know what failed:

greet(42) // returns MonadicError, caller ignores it

// Find it later
__tjs.errors() // → recent type errors (newest last, max 64)
__tjs.clearErrors() // → clear and return them
__tjs.getErrorCount() // → total since last clear

// Testing pattern: clear → run → check
__tjs.clearErrors()
runMyCode()
if (__tjs.errors().length > 0) {
  console.log('Unexpected type errors!')
}

On by default. Zero cost on the happy path — only writes when something happens.

It records more than errors

The failures that cost you a week are rarely the loud ones. A wasm{} block that quietly fell back to JavaScript is fine — until your page claims "⚡ SIMD" and is running plain JS. A typed array passed without wasmBuffer() is fine — except it's copied in and out on every call, and can be slower than the JS it replaced. Nothing throws. Every test is green.

So the runtime records those too, and records() gives you the whole flight:

__tjs.records() // → everything: type errors, wasm fallbacks, VM failures…
__tjs.records({ source: 'wasm' }) // → filter by source…
__tjs.records({ severity: 'notice' }) // → …and/or severity

// Your own code can report. Record what you'd want after an incident,
// not just what you're certain is broken.
__tjs.record({
  source: 'app',
  severity: 'notice',
  message: 'cache miss on user lookup',
  data: { userId: 42 },
})

__tjs.getRecordCount() // → total, every severity
__tjs.getDroppedCount() // → how many were lost to ring wrap

Each entry carries a source (type, wasm, vm, predicate, app, …) and a severity (error, warning, notice).

Two rules make it trustworthy:

The bias is deliberate: a false alarm costs one slot in a ring buffer, while a missing entry costs a debugging session with no evidence. When in doubt, it records.

Safe by Default

TJS functions are wrapped with runtime type validation by default:

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

add(1, 2) // 3
add('1', 2) // Error: expected number, got string
add(null, 2) // Error: expected number, got null

This provides excellent error messages and catches type mismatches at runtime.

Safety Markers: (?) and (!)

Control input validation with markers after the opening paren:

// (?) - Safe function: force input validation
function safeAdd(? a: 0, b: 0): 0 {
  return a + b
}

// (!) - Unsafe function: skip input validation
function fastAdd(! a: 0, b: 0): 0 {
  return a + b
}

fastAdd(1, 2)      // 3 (fast path, no validation)
fastAdd('1', 2)    // NaN (no validation, garbage in = garbage out)

The ! is borrowed from TypeScript's non-null assertion operator - it means "I know what I'm doing, trust me."

Return Type Safety: :, :?, :!

Control output validation with different colon styles:

// : normal return type (validation depends on module settings)
function add(a: 0, b: 0): 0 { return a + b }

// :? force output validation (safe return)
function critical(a: 0, b: 0):? 0 { return a + b }

// :! skip output validation (unsafe return)
function fast(a: 0, b: 0):! 0 { return a + b }

Combine input and output markers for full control:

// Fully safe: validate inputs AND outputs
function critical(? x: 0):? 0 { return x * 2 }

// Fully unsafe: skip all validation
function blazingFast(! x: 0):! 0 { return x * 2 }

There Is No unsafe { } Block

unsafe is an expression PREFIX, not a block. The wrapper decision is made at transpile time, so a block could not skip anything a marker does not already skip.

Mark the whole function ! when it is a hot path over trusted data:

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

Performance Characteristics

Mode Overhead Use Case
safety none 1.0x Metadata only, no wrappers
safety inputs ~1.15-1.3x Production with validation
safety all ~14x Debug — validates inputs and outputs
(!) function 1.0x Hot paths — explicit opt-out
(!) on a helper 1.0x Hot loops — mark the helper, not a block

Use (!) for internal functions that are called frequently with known-good data. Keep public APIs safe.

SafeFunction and Eval

Safe replacements for eval() and new Function() — fuel-metered, time-limited, and with no ambient authority: the code can reach only what you pass it. See Safe Eval, which covers the API, what the sandbox guarantees, and what it does not.

Testing

Compile-Time Tests

Tests run at transpile time and are stripped from output:

Type Email {
  example: 'test@example.com'
  predicate(x) { return x.includes('@') }
}

// This test runs during transpilation
test 'email validation' {
  if (!Email.check('user@example.com')) {
    throw new Error('valid email should pass')
  }
  if (Email.check('invalid')) {
    throw new Error('invalid email should fail')
  }
}

function sendEmail(to: Email) {
  // ...
}

The transpiled output contains only:

const Email = Type('Email', ...)
function sendEmail(to) { ... }

The test code evaporates - it verified correctness at build time.

Implicit Type Tests

Types with example have implicit tests - the example must pass the type check:

Type PositiveInt {
  example: 42
  predicate(x) { return Number.isInteger(x) && x > 0 }
}
// Implicit test: PositiveInt.check(42) must be true

If the example fails the predicate, transpilation fails.

Skip Tests Flag

For debugging or speed, skip test execution:

tjs emit file.tjs --dangerously-skip-tests

Tests are still stripped from output, but not executed.

Legacy Inline Tests

For runtime tests (e.g., integration tests), use standard test frameworks:

test('async operations work') {
  const data = await fetchData()
  expect(data).toBeDefined()
}

Differences from JavaScript

Removed/Discouraged

Feature Reason
var Use let or const — var is an error in .tjs
new Classes are callable without new
throw Return errors as values (monadic errors)
eval() Use Eval() or SafeFunction() — bare eval() is an error
Date Use Timestamp/LegalDate, or unsafe new Date(x)

Added

Feature Purpose
: example Required parameter with type
= example Optional parameter with default
-> Type Return type annotation
-? Type Return type with forced output validation
-! Type Return type with skipped output validation
(?) Mark function as safe (force validation)
(!) Mark function as unsafe (skip validation)
test 'name' {} Compile-time test block (evaporates)
mock {} Test setup block
unsafe expr Opt ONE construct out (unsafe new Date(0)) — not a block
|| in types Union types
Type Name = val Define runtime type with default
Generic<T> Define a parameterized runtime type
Foo = ... Bare assignment — auto-const (native TJS, first assign only)
SafeFunction Safe typed async replacement for Function
Eval Safe typed async replacement for eval()

Differences from TypeScript

Types are Values

// TypeScript - abstract type
interface User {
  name: string
  age: number
  email?: string
}

// TJS - concrete example
Type User {
  example: { name: '', age: 0, email: '' }
}

Runtime Preservation

TypeScript erases types at compile time. TJS preserves them:

function greet(name: 'World'): '' {
  return `Hello, ${name}!`
}

// At runtime:
greet.__tjs = {
  params: { name: { type: 'string', required: true } },
  returns: { type: 'string' },
}

This enables:

Generics

TypeScript generics become any in TJS, but constraints are preserved:

// TypeScript
function process<T extends { id: number }>(item: T): T

// TJS - constraint becomes validatable schema
function process(item: any): any
// process.__tjs.typeParams = { T: { constraint: '{ id: 0 }' } }

The constraint { id: number } becomes the example { id: 0 } - and can be validated at runtime!

No Type Gymnastics

TJS doesn't support:

If you need these, you probably need to rethink your approach. TJS favors simple, explicit types over clever type-level programming.

The __tjs Metadata

Every TJS function has attached metadata:

function createUser(name: 'Anonymous', age = 0): { id: '', name: '', age: 0 } {
  return { id: crypto.randomUUID(), name, age }
}

createUser.__tjs = {
  params: {
    name: { type: 'string', required: true, default: 'Anonymous' },
    age: { type: 'number', required: false, default: 0 },
  },
  returns: {
    type: 'object',
    shape: { id: 'string', name: 'string', age: 'number' },
  },
  // For generic functions:
  typeParams: {
    T: { constraint: '{ id: 0 }', default: null },
  },
}

This metadata enables:

  1. Runtime validation via wrap()
  2. Documentation generation via generateDocs()
  3. API schema export (OpenAPI, JSON Schema)
  4. IDE autocompletion
  5. Version-safe serialization

CLI Tools

tjs - The TJS Compiler

tjs check file.tjs      # Parse and type check
tjs emit file.tjs       # Output transpiled JavaScript
tjs run file.tjs        # Transpile and execute
tjs types file.tjs      # Output type metadata as JSON

tjsx - Quick Execution

tjsx script.tjs                    # Run a TJS file
tjsx script.tjs --name=value       # Pass arguments
tjsx -e "function f() { return 42 }"  # Evaluate inline
echo '{"x": 1}' | tjsx script.tjs --json  # JSON from stdin

Bun Plugin - Native .tjs Support

Run .tjs files directly with Bun using the preload plugin:

# Run a single file
bun --preload ./src/bun-plugin/tjs-plugin.ts script.tjs

# Enable globally in bunfig.toml
[run]
preload = ["./src/bun-plugin/tjs-plugin.ts"]

The plugin transpiles .tjs files on-the-fly with full runtime support (Type, Generic, Union, etc.).

Best Practices

1. Use Examples That Document

// Bad - meaningless example
function send(to: '', subject: '', body: '') {}

// Good - self-documenting
function send(
  to: 'user@example.com',
  subject: 'Hello!',
  body: 'Message content here...'
) {}

2. Validate at Boundaries

// Public API - safe by default
export function createUser(name: '', email: ''): { id: '', name: '', email: '' } {
  return createUserImpl(name, email)
}

// Internal - mark as unsafe for speed
function createUserImpl(! name: '', email: ''): { id: '', name: '', email: '' } {
  return { id: crypto.randomUUID(), name, email }
}

3. Return Errors, Don't Throw

// Bad
function divide(a: 0, b: 0): 0 {
  if (b === 0) throw new Error('Division by zero')
  return a / b
}

// Good
function divide(a: 0, b: 0): 0 {
  if (b === 0) return error('Division by zero')
  return a / b
}

4. Keep Types Simple

// Bad - over-engineered
function process(data: {
  items: [{ id: '', meta: { created: 0, tags: [''] } }],
}) {}

// Good - extract complex types
const Item = { id: '', meta: { created: 0, tags: [''] } }
function process(data: { items: [Item] }) {}

Transpilation

TJS transpiles to standard JavaScript:

// Input (TJS)
function greet(name: 'World'): '' {
  return `Hello, ${name}!`
}

// Output (JavaScript)
function greet(name = 'World') {
  return `Hello, ${name}!`
}
greet.__tjs = {
  params: { name: { type: 'string', required: true, default: 'World' } },
  returns: { type: 'string' },
}

The output is valid ES modules that work with any bundler (Vite, esbuild, webpack, Bun).

Further Reading