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
- Runtime safety in production - ~1.15x overhead on real functions
- Autocomplete always works -
__tjsmetadata attached regardless of safety - Monadic errors - type failures return error objects, not exceptions
- Escape hatches -
(!)for hot functions,unsafe {}for hot blocks - WASM acceleration -
wasm {}blocks for compute-heavy code
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:
- Predicate-safety verifier (
src/lang/predicate.ts, 0.8.4) — certifies a cluster of pure predicates and compiles them to native JS. This is where "safe is fast" comes from. - The
$predicateJSON-Schema keyword (0.9.0) — makes computational types serializable;tjs-lang/schemawires it into tosijs-schema, batteries-included. tjs-lang/css(0.9.0) — real CSS validators built from verified predicates: the thesis made concrete.- A portable predicate representation (serialized AST, not source) is the cross-language unlock — the same predicate runs in any host with a small predicate-VM.
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:
wrap()attaches__tjsmetadata but returns original function- No wrapper function, no
fn.apply(), no argument spreading - Introspection/autocomplete still works - metadata is always there
The (!) unsafe marker:
function hot(! x: 0): 0 { return x * 2 }
- Returns original function even with
safety: inputs - Use for hot paths where validation cost matters
- Autocomplete still works (metadata attached)
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:
- Parser extracts
wasm { }blocks with automatic variable capture - Compiler generates valid WebAssembly binary embedded as base64 in output
- SIMD intrinsics (
f32x4_*) for 4x float throughput wasmBuffer()for zero-copy typed array sharing between JS and WASMfallback { }provides JS path when WASM unavailable- See the WASM Quick Start for details
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):
- Functions know where they're defined
- Errors include source file and line
- No source maps needed - metadata is inline
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
- Self-documenting: description IS the type for humans and LLMs
- Runtime-checkable: predicate runs when needed
- Composable: union/intersection combine predicates
- Replaces regex-as-type: more expressive, leaves regexes for actual patterns
- Escapes TypeScript corner cases: no
Pick<Omit<Partial<Required<...>>>>
Predicates
Predicates are sync JS functions that run in our runtime:
- Pure expression evaluation (same
$exprnodes we have) - No async, no IO - type checks are in the hot path
- Sandboxed: no prototype access, no globals
- Portable: can be translated to any target
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:
browsernodebundebugproduction
Future:
swiftuiandroidioswin64llvm
Composition
&- both must match|- either matchestarget(production)stripstest {}blocks and debug code
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:
- Functions know what they were called and where they came from
- Errors include source locations and call stacks
- Runtime can reconstruct the full path to failure
// 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
- ✅ Monadic errors (AgentError class)
- ✅ try {} without catch transforms
- ✅ Error introspection — the flight recorder (
__tjs.records()/record(), source+severity, VM/wasm capture; shipped 0.10.0) - ✅ Call stack in errors — opt-in via
configure({ callStacks: true }) - ❌ --debug source mapping (still open)
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
test('description')- description required, explains what's being testedassert(expr)- auto-describes from source code on failureassert(expr, 'reason')- custom message overrides auto-description- Tests live next to the code they test
- Hoisted to bottom for execution order
- Stripped in
target(production) - Like Rust's
#[test]but inline
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:
- DOM types (HTMLElement, Event, etc.)
- Node types (Buffer, Stream, etc.)
- Platform types (SwiftUI views, Android widgets)
6. Future: Multi-Target Emission
The same TJS source compiles to:
- JavaScript (current)
- LLVM IR (native binaries)
- Swift (iOS/macOS)
- Kotlin (Android)
Platform builtins vary by target:
browser:document,window,fetchswiftui:VStack,HStack,Text,Buttonandroid:View,TextView,LinearLayout
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) |
| — | Mostly shipped (Type.toJSONSchema()/strip(), $predicate, functionMetaToJSONSchema); only OpenAPI gen + a tjs schema CLI remain |
|
| — | Beyond the north star, speculative (see Philosophy) — not near-term |
7. Safety Levels and Flags
Defaults: Safe and Correct
By default, TJS is strict:
- All type contracts enforced
- Lint errors block compilation
- Unknown types are errors
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)
--allow-unsafe: Complex/unknown types become best-effort runtime checks, warnings not errors--yolo: Skip all validation, emit anyway (for when you know what you're doing)
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:
- Lint - catch errors early
- Transpile - emit target code
- Test - run inline tests (unless
--no-test) - 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'
- Modules stored in IndexedDB (persistent across sessions)
- Validation on save (transpilation + inline tests)
- Version tracking and timestamps
- Local modules resolved first, then CDN
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
- Common packages have pinned versions for stability
- Service Worker caches fetched modules
- Import maps generated at runtime for browser
Bundler Compatibility
TJS also works inside conventional bundlers:
- Emits standard ES modules
- Bun plugin for direct
.tjsexecution - Or use playground's zero-build approach
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)
- Scan current file for identifiers
- Function names, variable names, parameter names
- Known atoms
- Like BBEdit - fast, useful, no dependencies
Level 1: Type-aware (fast, from syntax)
- Parameter
name: 'Sarah'→ string, offer string methods - Variable
x: 17→ number - No runtime needed, just syntax analysis
Level 2: Runtime introspection (when idle)
- Actually run code with mocks up to cursor
- Get real shapes from execution
- Nice to have, not blocking
Strategy
- Start fast (Level 0+1), upgrade async in background
- Typing rapidly? Stay at Level 0
- Paused 200ms? Try Level 1
- Paused 500ms? Try Level 2
- Cache aggressively - same signature = same completions
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:
- Property names
- Valid values for each property
- Vendor prefix variants
Versioned Imports Make This Insane
import { ship } from 'https://pkg.example.com/shipping@2.0.0/mod.tjs'
- Module already transpiled (cached by URL+version)
- Already introspected (we know its exports)
- Immutable (version pinned, never changes)
- Autocomplete for
ship.is instant forever
No node_modules crawling. No LSP server eating 4GB RAM. One file, one unit, instant knowledge.
Non-Goals
- External LSP dependencies
- TypeScript language server
- Crawling dependency graphs
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
- Same sandboxed evaluator we already have, exposed as builtin
- Safe by default - low fuel limit prevents runaway computation
- No
eval()- this is AST evaluation, not string execution - Fuel exhaustion returns error, doesn't throw
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:
- Track return types through call graph
- Generate
fn.__uncheckedvariants that skip input validation - Emit unchecked calls when input type is proven
- Array/iterable element types flow into loop bodies
- Subtype relationships allow broader → narrower without checks
Performance Target:
- Current inline validation: ~1.15-1.3x overhead
- With type flow: ~1.0x overhead (skip checks when types proven)
- Hot loops: 0x overhead (unchecked path)
JIT-Compiled Type Predicates
We own the language, so we can optimize hot type checks:
- Interpreted mode (default): Predicate runs as-is
- 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:
- The function knows where it's from (
fn.__tjs.source) - Can't get out of sync (it's part of the function)
- No external files, no tooling required
- Works in production without
.mapfiles
Why This Matters
- Auto-generated tests: Run with examples, expect example output
- API documentation: Always accurate, extracted from source
- LLM tool schemas: Generate OpenAI function calling format automatically
- Debug traces: Full path to failure with source locations
- Zero extra effort: You write the function, you get all of this
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
- Runtime-checkable: Not erased like TypeScript generics
- Self-documenting: Description for humans and LLMs
- Composable: Predicates can do real validation
- Practical: Makes complex generics achievable without gymnastics
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:
- Setter param type:
'' | 0 | null(union of string, number, null) - Getter return type:
Date(inferred from implementation)
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
- If left has
.Equals, callleft.Equals(right) - If right has
.Equals, callright.Equals(left) - Arrays/objects: recursive structural comparison
- Primitives: strict equality (no coercion)
Implementation Status
- ✅
Eq()/NotEq()honest equality (== and != — enabled by default in native TJS) - ✅
Is()/IsNot()deep structural comparison - ✅ Infix syntax transformation (
a Is b→Is(a, b)) - ✅ Custom equality protocol (
[tjsEquals]symbol and.Equalsmethod) - ✅ Honest
typeof(typeof null→'null'— enabled by default in native TJS) - ✅ TS-originated code retains JS semantics unless the dialect says otherwise (
TjsStrict)
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:
- Defensive semicolons
- ASI gotchas
- The entire "semicolon debate"
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
- Performance: WASM/GPU where it matters, TJS everywhere else
- Graceful degradation: Fallback ensures code always runs
- Single source: Don't maintain separate WASM/shader files
- Type-safe boundary: Args translated automatically at the boundary
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:
wrapClass()insrc/lang/runtime.ts- wraps classes with Proxy for callable behavioremitClassWrapper()generates wrapper code for transpiled classesno-explicit-newlint rule warns about unnecessarynewkeyword usage
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:
- Auto-registration: Class name → tag name (
MyDropdown→my-dropdown) - Inferrable names required: Must be PascalCase with multiple words
- Hot-reloadable: Components are hollow shells - redefining rebuilds all instances
- 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:
- Pattern matching (e.g., UPPERCASE) provides visual signal — you see
LABELand know it's tacit - File-scoped only — no cross-module leaking (same as
extendblocks) - Zero runtime cost — transpiler rewrites to property access at compile time
- Falls back to normal resolution — if an identifier IS defined, it takes precedence
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:
- CLI command:
tjs schema <file>dumps JSON Schema for all exported types and functions - OpenAPI generation from function signatures
- Schema validation at API boundaries (request/response matching)
- Round-trip: JSON Schema → TJS Type (for consuming external schemas)
Non-Goals
Full JS semantics (we're a subset that's portable)— superseded: TJS is now a superset of JS (TJS ⊇ JS, PRINCIPLES.md). Thedialect: 'js'mode preserves plain-JS semantics; native.tjsadds modes on top. JS is a subset of TJS, not the reverse.- Convoluted TS type gymnastics (maximum effort - best-effort conversion, ignore what we can't handle)