TJS Roadmap

Parser architecture — reassessment triggered (2026-09-02)

The standing note was "reassess a real lexer/CST if edge cases increase". They have. Seven remaining compat-corpus failures reduce to ~6 bugs, essentially all one class: a scanner that knows characters but not grammar. The proposal is NOT a rewrite — see docs/parser-primitives.md for the evidence, the two primitives that cover most of it, and the sequencing.

Philosophy

TJS is a practical language that targets multiple runtimes. The type system is descriptive rather than prescriptive - types explain what they are, validate at runtime, and degrade gracefully. No TypeScript gymnastics.

The runtime is JavaScript today, but it's our JavaScript - the sandboxed expression evaluator, the fuel-metered VM. Because the AST (not arbitrary JS) is the source of truth, other emission targets are possible; but the strategic center of gravity is the type system, not the backend — see "The North Star" below.


Executive Summary

TJS delivers runtime type safety with near-zero overhead. The key insight: single structured arguments enable inline validation that's 20x faster than schema interpretation.

The Performance Story

Mode Overhead Use Case
safety none 1.0x Production - metadata only, no wrappers
safety inputs ~1.15-1.3x Production with validation
safety all ~14x Debug - validates inputs and outputs
(!) unsafe 1.0x Hot paths - explicit opt-out
WASM blocks <1.0x Heavy computation - faster than JS

Inline validation = ~1.15x overhead on real-world functions with full runtime type checking. Trivial functions (e.g. x * 2) show ~1.3x because the typeof/instanceof checks dominate.

Why Inline Validation Wins

// TJS: pleasant syntax, fast validation (1.5x)
function createUser(input: { name: 'Alice'; email: 'a@b.com'; age: 30 }) {
  return save(input)
}

// TypeScript: painful syntax, no runtime safety (1.0x but unsafe)
function createUser({
  name,
  email,
  age,
}: {
  name: string
  email: string
  age: number
}) {
  return save({ name, email, age })
}

TJS generates inline type checks at transpile time:

if (
  typeof input !== 'object' ||
  input === null ||
  typeof input.name !== 'string' ||
  typeof input.email !== 'string' ||
  typeof input.age !== 'number'
) {
  return { $error: true, message: 'Invalid input', path: 'createUser.input' }
}

No schema interpretation. JIT-friendly. 20x faster than Zod/io-ts style validation.

What You Get

The Design Alignment

The idiomatic way to write TJS (single structured argument) is also the fastest way. Language design and performance goals are aligned - you don't have to choose between clean code and fast code.

The North Star: predicates as JSON-Schema's missing computational half

The strategic direction is JSON-Schema + $predicate as the single source of truth for TJS types. A TJS type is example-driven and validates at runtime; the deeper insight is that a verified predicate — a pure, fuel-bounded, composable function — is the computational half JSON-Schema lacks. Structure for naive validators, $predicate for aware ones (progressive enhancement). Shipped toward it:

The active campaign is "safe is fast": measure the overhead, then propagate verified→native compilation so the safe path is the fast path. See docs/type-system-north-star.md and PRINCIPLES.md.

Beyond the north star: multi-target native emission (speculative)

The AST is the source of truth, so emitting something other than JS (LLVM IR, Swift, Kotlin) is possible — but it's not clearly a win yet. The performance case over "our JS + wasm {} for hot paths" is unproven, and it's a large investment. Parked as a someday-maybe, explicitly beyond the north star — not a near-term goal.


Technical Aspects

Performance

Runtime Validation Overhead:

Plain function call:     1.2ms / 1M calls (baseline)
safety: 'none':          1.2ms / 1M calls (~1.0x) - no wrapper
safety: 'inputs':        1.4ms / 1M calls (~1.15x) - inline validation*
safety: 'all':           ~14x / 1M calls - validates args + return

* Inline typeof/instanceof checks, no try/finally, no schema interpretation
  ~1.15x for functions with real work, ~1.3x for trivial functions

Why inline validation is fast:

// The happy path - single structured argument
function process(input: { x: 0; y: 0; name: 'default' }) {
  return input.x + input.y
}

// Generates inline type checks (20x faster than schema interpretation):
if (
  typeof input !== 'object' ||
  input === null ||
  typeof input.x !== 'number' ||
  typeof input.y !== 'number' ||
  typeof input.name !== 'string'
) {
  return { $error: true, message: 'Invalid input', path: 'process.input' }
}

This makes safety: 'inputs' viable for production.

Why safety: 'none' is free:

The (!) unsafe marker:

function hot(! x: 0): 0 { return x * 2 }

WASM blocks:

function compute(x: 0, y: 0) {
  const scale = 2
  return wasm {
    return x * y * scale  // Compiles to WebAssembly
  }
}
// Variables (x, y, scale) captured automatically from scope
// Same code runs as JS fallback if WASM unavailable

With explicit fallback (when WASM and JS need different code):

function transform(arr: []) {
  wasm {
    for (let i = 0; i < arr.length; i++) { arr[i] *= 2 }
  } fallback {
    return arr.map(x => x * 2)  // Different JS implementation
  }
}

WASM compilation is fully implemented:

Debugging

Source locations in errors:

{
  $error: true,
  message: 'Expected string but got number',
  path: 'greet.name',           // which parameter
  loc: { start: 15, end: 29 },  // source position
  stack: ['main', 'processUser', 'greet.name']  // call chain (debug mode)
}

Debug mode:

configure({ debug: true })
// Errors now include full call stacks

The --debug flag (planned):

For Human Coding

Intuitive syntax:

// Types ARE examples - self-documenting
function greet(name: 'World', times: 3): '' {
  return (name + '!').repeat(times)
}

// Autocomplete shows: greet(name: string, times: number): string
// With examples: greet('World', 3)

Module-level safety:

safety none  // This module skips validation

function hot(x: 0): 0 {
  return x * 2  // No wrapper, but autocomplete still works
}

Escape hatches:

// Per-function: skip validation for this function
function critical(! data: object) { ... }

// Per-block: skip validation for calls inside
unsafe {
  for (let i = 0; i < 1000000; i++) {
    hot(i)  // No validation overhead
  }
}

For Agent Coding

Introspectable functions:

greet.__tjs = {
  params: { name: { type: 'string', required: true, example: 'World' } },
  returns: { type: 'string' },
}

// Agents can read this to understand function signatures
// LLMs can generate function call schemas automatically

Monadic errors:

const result = riskyOperation()
if (isMonadicError(result)) {
  // Error is a value, not an exception
  // Agent can inspect and handle gracefully
}

Fuel metering:

// Agents run with fuel limits - can't run forever
vm.run(agentCode, { fuel: 10000 })

1. Type() Builtin

A new builtin for defining types with descriptions and runtime validation.

Forms

// Full form: description + predicate
const ZipCode = Type('5-digit US zip code', (s) => /^\d{5}$/.test(s))
const PositiveInt = Type(
  'positive integer',
  (n) => Number.isInteger(n) && n > 0
)
const MatchingPasswords = Type(
  'passwords must match',
  (o) => o.password === o.confirmPassword
)

// Schema shorthand (common case)
const Email = Type('valid email', s.string.email)
const Age = Type(s.number.min(0).max(150))

// Description optional when schema is self-explanatory
const UserId = Type(s.string.uuid)

Why

Predicates

Predicates are sync JS functions that run in our runtime:

Simple Syntax Sugar Remains

// These still work - Type() is the escape hatch, not the default
function greet(name: 'World', times = 1) { ... }
function delay(ms = 1000) { ... }
function fetch(url: '', timeout = +5000) { ... }

2. Conditional Compilation with target()

Explicit target blocks for platform-specific code.

target(browser) {
  document.body.appendChild(el)
}

target(node) {
  process.stdout.write(str)
}

target(browser & debug) {
  console.log('Debug mode in browser')
}

target(browser | node) {
  // Runs in either
}

Targets

Current:

Future:

Composition

3. Monadic Errors and Debug Mode

try {} Without catch

Bare try blocks convert to monadic error returns:

try {
  let data = riskyOperation()
  process(data)
}
// No catch - transforms to:
// if error, return { $error: true, message, op, cause }

Errors become values, not exceptions. Subsequent code is skipped (monadic flow).

AgentError Introspection

Errors carry full context:

{
  $error: true,
  message: 'Connection refused',
  op: 'httpFetch',              // which atom failed
  cause: <original exception>,  // for debugging
  // With --debug:
  source: 'orders.tjs:47:3',    // exact location
  callStack: [                  // how we got here
    'ship() at orders.tjs:47:3',
    'processOrder() at checkout.tjs:123:5',
    'handleSubmit() at form.tjs:89:12'
  ]
}

--debug Flag

When transpiled with --debug:

// With --debug, errors show:
// Error: Invalid ZipCode at ship() (orders.tjs:47:3)
//   called from processOrder() (checkout.tjs:123:5)
//   called from handleSubmit() (form.tjs:89:12)

Current State

4. test('description') {} Blocks

Inline tests that hoist to bottom of file for execution.

const ZipCode = Type('5-digit US zip code', (s) => /^\d{5}$/.test(s))

test('ZipCode validates correctly') {
  assert(ZipCode.check('12345'))
  assert(!ZipCode.check('1234'))
  assert(!ZipCode.check('123456'))
  assert(!ZipCode.check('abcde'), 'letters should fail')
}

function ship(to: ZipCode, quantity: PositiveInt) {
  // ...
}

test('ship requires valid zip and quantity') {
  ship('12345', 1)  // ok
  assertThrows(() => ship('bad', 1))
}

Failure Output

FAIL: ZipCode validates correctly
  assert(!ZipCode.check('1234'))  ← auto-generated from source

FAIL: ship requires valid zip and quantity
  letters should fail  ← custom message when provided
  assert(!ZipCode.check('abcde'), 'letters should fail')

Rules

5. Pragmatic Native Types

Trust constructor names for platform types:

// Instead of shipping 50KB of DOM type definitions:
// - el instanceof HTMLElement checks constructor.name
// - If someone lies about their constructor, that's on them

This applies to:

6. Future: Multi-Target Emission

The same TJS source compiles to:

Platform builtins vary by target:

The AST is the source of truth. Targets are just emission strategies.


Implementation Status

# Feature Status Notes
1 Type() ✅ Full form with description + predicate, Union, Generic, Enum
2 target() ❌ Conditional compilation
3 Monadic Errors ✅ MonadicError with path, expected, actual, callStack
4 test() blocks ✅ extractTests, assert/expect, mock blocks, CLI
5 Pragmatic natives ⏳ Some constructor checks exist
6 Multi-target ❌ Future - JS only for now
7 Safety levels ✅ none/inputs/all + (!)/(?) + unsafe {}
8 Module-level safety ✅ safety none directive parsed and passed
9 Single-pass ✅ Bun plugin: direct bun file.tjs execution
10 Module system ✅ IndexedDB store, esm.sh CDN, pinned versions
11 Autocomplete ✅ CodeMirror integration, globals, introspection
12 Eval() / SafeFunction ✅ Both exported and tested in runtime
13 Function introspection ✅ __tjs metadata with params, returns, examples
14 Generic() ✅ Runtime-checkable generics with TPair, TRecord
15 Asymmetric get/set ✅ JS native get/set captures asymmetric types
16 == that works ✅ Eq/NotEq + Is/IsNot, on by default in native TJS, honest typeof
17 WASM blocks ✅ Full: SIMD intrinsics, wasmBuffer, fallback, base64 embed
17a WASM libraries ✅ v0.8.0: wasm function declarations, cross-file composition, wasm-to-wasm calls, tjs-lang/linalg MVP, module consolidation (one WebAssembly.Module per file), tree-shaking + transitive walk. See wasm-library-plan.md.
18 Death to new ✅ wrapClass + no-explicit-new lint rule
19 Linter ✅ unused vars, unreachable code, no-explicit-new
20 TS→TJS converter ✅ tjs convert — proven on Zod, Effect, Radash, etc.
21 Docs generation ✅ Auto-generated with emit, --no-docs, --docs-dir
22 Class support ✅ TS→TJS class conversion, private→#, Proxy wrap
23 JSON Schema ✅ Type.toJSONSchema(), Type.strip(), fn.__tjs.schema()
24 Error history ✅ Ring buffer of recent MonadicErrors, on by default
25 Polymorphic functions ✅ Multiple same-name declarations merge into dispatcher
26 Local class extensions ✅ extend String { } without prototype pollution
27 const! ✅ Compile-time immutability, zero runtime cost
28 FunctionPredicate ✅ First-class function types with params/returns/contract
29 .d.ts generation ✅ generateDTS() from TJS transpilation results
30 @tjs annotations ✅ /* @tjs ... */ comments enrich TS→TJS output

fromTS Transpiler Compatibility

Proven against real-world TypeScript libraries (zero test regressions):

Library Files LOC Tests Status
Radash 10 ~3K 340/340 ✅ 100%
Superstruct 8 1.8K 225/225 ✅ 100%
ts-pattern 17 5.5K 453/453 ✅ 100%
Zod 114 ~30K 1842/1842 ✅ 100%
Kysely 279 ~20K (DB req'd) ✅ transpiles
Effect 363 120K (not run) ✅ transpiles

Scripts: bun scripts/compat-radash.ts, compat-superstruct.ts, compat-ts-pattern.ts, compat-zod.ts, compat-kysely.ts, compat-effect.ts

Next Up

Priority Feature Why
1 "Safe is fast" campaign Measure overhead → propagate verified-predicate → native. The north star.
2 Per-mode off directive Mode control is add-only (#7); made acute by dict-defaults/security in 0.12.0
3 Curated predicate completions suggest() gains per-value descriptions + cursor-placement templates, discoverable both in editors AND by an AI via introspection (__tjs/.d.ts). 0.13.0 candidate; TODO §Predicate types #4b/#4c.
4 target() Conditional compilation for build flags
5 Tacit proxies Transpiler-assisted implicit namespaces (see Ideas)
— JSON Schema from types Mostly shipped (Type.toJSONSchema()/strip(), $predicate, functionMetaToJSONSchema); only OpenAPI gen + a tjs schema CLI remain
— Multi-target emission Beyond the north star, speculative (see Philosophy) — not near-term

7. Safety Levels and Flags

Defaults: Safe and Correct

By default, TJS is strict:

Escape Hatches

tjs build app.tjs                    # strict, safe defaults
tjs build app.tjs --allow-unsafe     # let nasty TS libs pass through
tjs build app.tjs --yolo             # bypass all safeguards (--just-fucking-doit)

Lint Integration

Lint errors block safe builds. Not warnings - errors. If you want to ship broken code, use --yolo.

8. Single-Pass Pipeline

One command does everything:

tjs build app.tjs

In a single pass:

  1. Lint - catch errors early
  2. Transpile - emit target code
  3. Test - run inline tests (unless --no-test)
  4. Docs - extract documentation from types and descriptions

No separate tjs lint && tjs build && tjs test && tjs docs. One pass, all the information is right there.

9. Module System ✅

Local Module Store (IndexedDB)

The playground provides persistent module storage:

// Save a module
await store.save({ name: 'my-utils', type: 'tjs', code: source })

// Import it in another module
import { helper } from 'my-utils'

CDN Integration (esm.sh)

npm packages resolve via esm.sh with pinned versions:

import { debounce } from 'lodash' // -> https://esm.sh/lodash@4.17.21
import { z } from 'zod' // -> https://esm.sh/zod@3.22.0

Bundler Compatibility

TJS also works inside conventional bundlers:

Your choice. We don't force either approach.

10. Autocomplete by Introspection

IDE support via runtime introspection, not static .d.ts files.

Heuristic Levels (Progressive Fallback)

Level 0: Local symbols (instant, always)

Level 1: Type-aware (fast, from syntax)

Level 2: Runtime introspection (when idle)

Strategy

CSS: Use the Browser

CSS autocomplete is pathological to implement manually - hundreds of properties, thousands of values, vendor prefixes. The browser already knows all of this.

// Let the browser do the work
const style = document.createElement('div').style
Object.keys(style) // Every CSS property
CSS.supports('display', 'grid') // Validate values

Don't ship CSS type definitions. Query the browser at runtime for:

Versioned Imports Make This Insane

import { ship } from 'https://pkg.example.com/shipping@2.0.0/mod.tjs'

No node_modules crawling. No LSP server eating 4GB RAM. One file, one unit, instant knowledge.

Non-Goals

11. Eval() - Safe Expression Evaluation

A builtin for evaluating expressions with fuel limits:

// Low default fuel - won't run away
Eval('2 + 2') // ~100 fuel default

// Explicitly allow more for complex work
Eval('fibonacci(20)', { fuel: 1000 })

// Restrict for untrusted input
Eval(userInput, { fuel: 10 })

Why

Options

Eval(expression, {
  fuel: 100, // max fuel (default: 100)
  context: {}, // variables available to expression
  timeout: 1000, // ms timeout (default: fuel * 10)
})

Ideas Parking Lot

Type Flow Optimization (Compile-Time)

Skip redundant type checks when types are already proven. The transpiler tracks type information through the call graph:

Scenario 1: Chained Functions

function validate(x: 0): 0 {
  return x * 2
}
function process(x: 0): 0 {
  return x + 1
}

// Source
const result = process(validate(input))

// Naive: validate checks input, process checks validate's output
// Optimized: validate's return type matches process's input - skip second check

// Transpiled (optimized)
const _v = validate(input) // validates input once
const result = process.__unchecked(_v) // skips redundant check

Scenario 2: Loop Bodies

function double(x: 0): 0 {
  return x * 2
}
const nums = [1, 2, 3]

// Source
nums.map(double)

// Naive: double validates x on every iteration (3 checks)
// Optimized: nums is number[], so each element is number - skip all checks

// Transpiled (optimized)
nums.map(double.__unchecked) // zero validation overhead in loop

Scenario 3: Subtype Relationships

const PositiveInt = Type(
  'positive integer',
  (n) => Number.isInteger(n) && n > 0
)
function increment(x: 0): 0 {
  return x + 1
}

const val: PositiveInt = 5
increment(val) // PositiveInt is subtype of number - skip check

Implementation:

  1. Track return types through call graph
  2. Generate fn.__unchecked variants that skip input validation
  3. Emit unchecked calls when input type is proven
  4. Array/iterable element types flow into loop bodies
  5. Subtype relationships allow broader → narrower without checks

Performance Target:

JIT-Compiled Type Predicates

We own the language, so we can optimize hot type checks:

  1. Interpreted mode (default): Predicate runs as-is
  2. Compiled mode (hot path): If a Type validates thousands of times, JIT-compile it
const ZipCode = Type('5-digit zip', (s) => /^\d{5}$/.test(s))

// First N calls: interpreted, collecting stats
// Call N+1: "this is hot, compile it"
// Now it's TypeBox-fast without ahead-of-time compilation

For target(production), we could inline validators entirely:

// Source
function ship(to: ZipCode) { ... }

// Transpiled (production)
function ship(to) {
  if (typeof to !== 'string' || !/^\d{5}$/.test(to))
    throw new TypeError('expected 5-digit zip')
  ...
}

No runtime Type object, no .check() call - just inlined validation.

Unlike TypeBox (which precompiles via eval and can't handle dynamic types), we can do both interpreted and compiled because we control the compiler.


12. Function Introspection

Functions are self-describing. A single signature provides types, examples, and tests:

function checkAge(name: 'Anne', age = 17): { canDrink: false } {
  return { canDrink: age >= 21 }
}

From this you get:

Extracted Value
Types name: string, age: number, returns { canDrink: boolean }
Examples name = 'Anne', age = 17, output { canDrink: false }
Implicit test checkAge('Anne', 17) should return { canDrink: false }
Docs The signature IS the documentation

Runtime Metadata

Every function carries introspectable metadata:

checkAge.__tjs
// {
//   params: {
//     name: { type: { kind: 'string' }, required: true },
//     age: { type: { kind: 'integer' }, required: false, default: 17 }
//   },
//   returns: { type: { kind: 'object' } },
//   source: 'users.tjs:42'
// }

Debug Builds

With --debug, functions know where they are and where they were called from:

// Error output includes full trace:
// Error: Invalid ZipCode at ship() (orders.tjs:47:3)
//   called from processOrder() (checkout.tjs:123:5)
//   called from handleSubmit() (form.tjs:89:12)

No Source Maps

Source maps are a hack - external files that get out of sync, break in large builds, and require tooling support. TJS replaces them entirely:

Why This Matters

13. Generic() Builtin

Turing completeness by design, not by accident. TypeScript's generics grew into an unreadable type-level programming language. TJS assumes Turing completeness from the start - the predicate is just code:

Following the Type() pattern, generics are runtime-inspectable and predicate-validated:

const List = Generic(
  'homogeneous list of items',
  [T],
  (x, [T]) => Array.isArray(x) && x.every(item => T.check(item))
)

const Map = Generic(
  'key-value mapping',
  [K, V = any],
  (x, [K, V]) => x instanceof Map && [...x.keys()].every(k => K.check(k))
)

// Usage
const strings: List(string) = ['a', 'b', 'c']
const lookup: Map(string, number) = new Map([['age', 42]])

Why

Converting convoluted TypeScript generics (Pick<Omit<Partial<...>>>) is nice-to-have, not a priority.

14. Asymmetric Get/Set ✅

Properties that accept a broader type on write but return a narrower type on read. TJS uses JavaScript's native getter/setter syntax which naturally captures asymmetric types:

class Timestamp {
  #value

  constructor(initial: '' | 0 | null) {
    this.#value = initial === null ? new Date() : new Date(initial)
  }

  // Setter accepts string, number, or null
  set value(v: '' | 0 | null) {
    this.#value = v === null ? new Date() : new Date(v)
  }

  // Getter always returns Date
  get value() {
    return this.#value
  }
}

const ts = Timestamp('2024-01-15')
ts.value = 0 // SET accepts: string | number | null
ts.value // GET returns: Date (always normalized)

The type metadata captures the asymmetry:

This matches real-world APIs (DOM, dates, etc.) without TypeScript's painful workarounds.

15. == That Works

JavaScript's == is broken (type coercion chaos). In native TJS, ==/!= use honest equality by default (no coercion, unwraps boxed primitives). TS-originated code retains JS semantics unless the file is native .tjs or TjsStrict sets the dialect. Is/IsNot provide explicit deep structural comparison:

Operator Behavior
Is Value equality - structural comparison for arrays/objects, calls .Equals if defined
IsNot Value inequality - negation of Is
=== Identity - same object reference (rarely needed)
// Infix syntax - clean and readable
[1, 2] Is [1, 2]       // true (structural)
[1, 2] IsNot [1, 2, 3] // true (different length)
5 Is "5"               // false (no coercion - different types)

// Custom equality via .Equals hook
const p1 = {
  x: 1,
  Equals(o) {
    return this.x === o.x
  },
}
const p2 = { x: 1 }
p1 Is p2   // true (via .Equals hook)
p1 === p2  // false (different objects)

Rules

  1. If left has .Equals, call left.Equals(right)
  2. If right has .Equals, call right.Equals(left)
  3. Arrays/objects: recursive structural comparison
  4. Primitives: strict equality (no coercion)

Implementation Status

16. Death to Semicolons

In native TJS newlines are meaningful — unconditionally, because the file extension is the gate. TS-originated code retains JS semantics unless TjsStrict sets the dialect. This:

foo()

Is two statements (foo and ()), not a function call foo().

This eliminates:

The only code this breaks is pathological formatting that nobody writes intentionally.

17. Polyglot Blocks (WASM, Shaders, etc.)

Target-specific code blocks with automatic variable capture and fallback:

// WASM for performance-critical path - variables captured automatically
function matmul(vertices: Float32Array, matrix: Float32Array) {
  wasm {
    for (let i = 0; i < vertices.length; i += 3) {
      // matrix multiply using vertices and matrix from scope
    }
  }
  // Body runs as JS if WASM unavailable
}

// With explicit fallback when implementations differ:
function transform(data: Float32Array) {
  wasm {
    // WASM-optimized in-place mutation
    for (let i = 0; i < data.length; i++) { data[i] *= 2 }
  } fallback {
    // JS uses different approach
    return data.map(x => x * 2)
  }
}

// GPU shader (future)
glShader {
  gl_Position = projection * view * vec4(position, 1.0)
  fragColor = color
} fallback {
  // CPU fallback
}

// Debug-only code (stripped in production)
debug {
  console.log('state:', state)
  validateInvariants()
}
// No fallback needed - just doesn't run in production

Pattern

target(args?) {
  // target-specific code (compiled/translated)
} fallback? {
  // universal TJS fallback (optional for some targets)
}

Targets

Target Compiles to Fallback Use case
wasm WebAssembly Required CPU-intensive computation
glShader GLSL Required GPU graphics
metal Metal Shading Language Required Apple GPU
debug TJS (stripped in prod) None Debugging, invariants

Why

WASM Libraries (cross-file, composable)

✅ Shipped in 0.8.0. tjs source files can declare reusable wasm functions that other files import, with module composition at transpile time so intra-library calls stay inside the wasm module (no JS↔wasm boundary). First stdlib target tjs-lang/linalg shipped. Full design: wasm-library-plan.md (all phases complete). Deferred follow-ups (linalg expansion, i32/f32/v128 return types) live in TODO.md.

18. Classes and Components

TJS embraces classes, but eliminates JS footguns and enables cross-platform UI components.

Death to new ✅

The new keyword is redundant ceremony. TJS handles it automatically:

class User {
  constructor(public name: string) {}
}

// Both work identically in TJS:
const u1 = User('Alice') // TJS way - clean
const u2 = new User('Alice') // Lint warning: "use User() instead of new User()"

If you call Foo() and Foo is a class, TJS calls it with new internally. No more "Cannot call a class as a function" errors.

Implementation:

Component Base Class

Component is the platform-agnostic UI primitive:

class MyDropdown extends Component {
  // Shared logic - runs everywhere
  items: string[] = []
  selectedIndex: number = 0

  select(index: number) {
    this.selectedIndex = index
    this.emit('change', this.items[index])
  }

  // Platform-specific blocks
  web() {
    // CSS, DOM events, ARIA attributes
    this.style = `
      .dropdown { position: relative; }
      .dropdown-menu { position: absolute; }
    `
  }

  swift() {
    // SwiftUI modifiers, gestures
    Menu {
      ForEach(items) { item in
        Button(item) { select(items.indexOf(item)) }
      }
    }
  }

  android() {
    // Jetpack Compose
    DropdownMenu(expanded = expanded) {
      items.forEach { item ->
        DropdownMenuItem(onClick = { select(items.indexOf(item)) }) {
          Text(item)
        }
      }
    }
  }
}

Web Components (HTMLElement)

For web, extends HTMLElement auto-registers custom elements:

class MyDropdown extends HTMLElement {
  // Automatically registers <my-dropdown>
}

class UserCard extends HTMLElement {
  // Automatically registers <user-card>
}

// Error: can't infer tag name
class Thang extends HTMLElement {} // "can't infer tag-name from 'Thang'"

// OK: modest names work
class MyThang extends HTMLElement {} // <my-thang>

Key features:

  1. Auto-registration: Class name → tag name (MyDropdown → my-dropdown)
  2. Inferrable names required: Must be PascalCase with multiple words
  3. Hot-reloadable: Components are hollow shells - redefining rebuilds all instances
  4. Smart inheritance: ARIA roles and behaviors wired automatically

Why Hollow Components?

The web component registry is a source of pain - you can't redefine elements. TJS sidesteps this:

// First definition
class MyButton extends HTMLElement {
  render() {
    return '<button>v1</button>'
  }
}

// Later redefinition (hot reload, live coding)
class MyButton extends HTMLElement {
  render() {
    return '<button>v2</button>'
  }
}
// All existing <my-button> elements rebuild with new implementation

The registered element is a hollow proxy that delegates to the current class definition.

Platform Adapters

The Component class compiles to platform-native code:

TJS Source Web Output SwiftUI Output Compose Output
class Foo extends Component Custom Element struct Foo: View @Composable fun Foo()
this.state = x Reactive update @State var state mutableStateOf()
this.emit('click') dispatchEvent() Callback closure Lambda
web { } Compiled Stripped Stripped
swift { } Stripped Compiled Stripped

The class definition is the source of truth. Platform blocks contain native code for each target - no CSS-in-JS gymnastics trying to map everywhere.

Tacit Proxies — Implicit Namespaces

A tacit directive that tells the transpiler: "unresolved identifiers matching this pattern should be looked up on this object at runtime." No Proxy needed at runtime — pure transpile-time rewriting.

tacit elements as UPPERCASE

// LABEL is undefined, but matches the UPPERCASE pattern
// Transpiler rewrites to: elements.label(...)
LABEL('Edit me', INPUT({ placeholder: 'foo' }))

This gives you JSX-like ergonomics without special syntax, without build magic, and it's general-purpose — DOM elements, SQL builders, test DSLs, anything where you want a namespace of functions without explicit imports.

Key design constraints:

Related prior art: Python's from module import *, Ruby's method_missing, Kotlin's scope functions. TJS version is explicit (you declare the pattern) and compile-time (no runtime dispatch).

JSON Schema Deepening

Current: Type.toJSONSchema(), fn.__tjs.schema(), functionMetaToJSONSchema().

Next steps:

Non-Goals