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
- TJS Interactive Examples
- TJS: Typed JavaScript
- TJS for JavaScript Programmers
- JS Footguns That TJS Quietly Fixes
- WASM in TJS
- WASM Quick Start
- Playground Imports
- Context: Working with tosijs-schema
- TJS Performance Guide
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:
= valuesets a default for instantiationexample:in block sets an example for testing/documentation- When both are present, they serve different purposes
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:
errors()stays narrow. It returns type errors and only type errors. If notices leaked into it, everyclearErrors() → run → expect nonetest would start failing on things that aren't errors.- Recording never changes behavior. It doesn't throw, doesn't log unbidden, and doesn't touch control flow — a recorder that alters the flight isn't a recorder. It also fires once per site, never once per call, so it can't become the performance problem it exists to detect.
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:
- Runtime validation
- Auto-generated documentation
- API schema generation
- Better error messages
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:
- Conditional types
- Mapped types
- Template literal types
inferkeyword
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:
- Runtime validation via
wrap() - Documentation generation via
generateDocs() - API schema export (OpenAPI, JSON Schema)
- IDE autocompletion
- 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
- Performance guide - Performance characteristics
- ajs.md - The sandboxed agent language
- API Documentation - Generated from source