Flight Recorder

An aircraft's flight recorder writes down everything that happens, so that after a crash someone can read back what went wrong. The TJS runtime keeps one too, an error history: it records every error, and the near-misses that are not errors yet (a WebAssembly block that quietly fell back to slower JavaScript, say).

Why errors need recording

TJS returns errors instead of throwing them. A call with a bad argument hands back an error value (a MonadicError) in place of its result, and the program carries on. That keeps one bad value from crashing a page, but the error vanishes if nobody checks the return value:

function createUser(name: 'Ada') {
  return { name }
}

const result = createUser(42) // a number, not a string
console.log('carried on; got an error?', isMonadicError(result)) // → true
console.log(result.message)
// → Expected string for '…:1:createUser.name', got number

The colon value is an example, not a TypeScript type: name: 'Ada' means name must be a string, so createUser(42) is a type error. (See parameters.) Here the code did check, with isMonadicError. Most code doesn't.

So the runtime keeps a record of what it noticed. It is a fixed-size buffer, a ring: it holds the most recent 64 entries and overwrites the oldest. It is on by default and costs nothing on calls that succeed.

You read it through the __tjs object. Inside a TJS file, __tjs is that file's own runtime and sees the calls made to that file's functions. globalThis.__tjs is the shared one that every file (and the AJS VM) reports to; more on that below. Both exist whenever a TJS runtime is installed: under tjs run, the bun plugin, this site, or after installRuntime() from tjs-lang/runtime.

errors(): type errors only

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

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

__tjs.clearErrors()

greet('Alice')
double(5)
console.log('After correct calls:', __tjs.errors().length) // → 0

// Wrong types, and nobody checks the return values
greet(42)
double('oops')

console.log('After bad calls:', __tjs.errors().length) // → 2
for (const err of __tjs.errors()) {
  console.log('  -', err.message)
}

// The testing pattern: clear, run, expect nothing
__tjs.clearErrors()
greet('Bob')
console.log('Test:', __tjs.errors().length === 0 ? 'PASS' : 'FAIL') // → PASS

greet(42) and double('oops') returned errors nobody looked at, and the program carried on. The runtime wrote both down, and __tjs.errors() hands them back. Each message names the place, function and parameter, then what was expected and what arrived:

Expected string for '<source>:1:greet.name', got number
Expected integer for '<source>:5:double.x', got string

The place is the file name and line (<source> when the code has no file name, as here).

errors() returns type errors and nothing else, never warnings or notices. That is what makes "clear, run, expect none" a reliable test.

records(): everything

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

__tjs.clearRecords() // empties the whole ring, type errors included

// Your own code can report too
__tjs.record({
  source: 'app',
  severity: 'notice',
  message: 'cache miss on user lookup',
  data: { userId: 42 },
})

greet(99) // a type error is also a record

// Everything recorded, oldest first
for (const r of __tjs.records()) {
  console.log(`[${r.source}/${r.severity}]`, r.message)
}
// → [app/notice] cache miss on user lookup
// → [type/error] Expected string for '…:1:greet.name', got number

console.log('Notices:    ', __tjs.records({ severity: 'notice' }).length) // → 1
console.log('Type errors:', __tjs.records({ source: 'type' }).length) // → 1
console.log('errors():   ', __tjs.errors().length) // → 1: the notice is not in it

Type errors, your own __tjs.record(...) entries and the runtime's near-misses all land in the same ring. Pass { source }, { severity } or both to filter it.

There is one ring per runtime, so the two clears differ only in what they return. clearErrors() empties everything, records included, and returns the type errors it removed; clearRecords() returns every entry.

Loss is visible

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

__tjs.clearRecords()
for (let i = 0; i < 100; i++) {
  greet(i) // a type error on every call
}
console.log('Type errors:   ', __tjs.getErrorCount()) // → 100
console.log('Total recorded:', __tjs.getRecordCount()) // → 100
console.log('Still buffered:', __tjs.records().length) // → 64
console.log('Lost to wrap:  ', __tjs.getDroppedCount()) // → 36

The ring keeps the last 64 entries. getErrorCount() and getRecordCount() keep counting past that (until the next clear, which resets them), and getDroppedCount() says how many fell off the end. A recorder that silently dropped evidence would be lying about what happened.

Recording never changes what your program does: it does not throw, does not log unless you ask it to, and does not alter control flow.

What a record looks like

Every entry in records() is a plain object:

Field Meaning
source Who reported it: 'type', 'wasm', 'vm', 'app', or your own
severity 'error', 'warning' or 'notice'
message A human-readable description
data Optional details; VM failures put the failing atom here as op
error The MonadicError itself, on type errors
stack The call stack, when configure({ callStacks: true }) is on

configure comes from tjs-lang/lang; see the gotchas for when it takes effect.

What the runtime reports today:

The wasm entries are the reason the recorder takes non-errors. A block that quietly fell back to JavaScript is correct, just slow: nothing throws and every test stays green. That kind of problem takes a week to find without evidence. A false alarm costs one slot in the ring; a missing entry costs a debugging session with nothing to go on. So the runtime records liberally.

Type errors are recorded on every call, as the loop above shows. The near-miss notices are recorded once per site, not once per call, so a hot loop full of slow WebAssembly calls does not fill the ring.

AJS runs land here too

AJS is TJS's sandboxed agent language (see the AJS guide). An agent runs on a VM with a fuel budget, and stops when the fuel runs out. A failed run does not throw either: vm.run returns its error in the result. The VM also writes each failure into the shared recorder, globalThis.__tjs, under source: 'vm', so a run that ran out of fuel or was refused leaves evidence even if the caller ignores the result. That matters for code like The Universal Endpoint, which runs agents other people wrote.

import { AgentVM, transpile } from 'tjs-lang'

const agent = transpile(`
  function countUp({ n: 10 }) {
    let total = 0
    let i = 0
    while (i < n) {
      total = total + i
      i = i + 1
    }
    return { total }
  }
`)

const vm = new AgentVM()
const recorder = globalThis.__tjs // the shared recorder the VM writes to

recorder.clearRecords()

// Enough fuel: a clean run records nothing
const ok = await vm.run(agent.ast, { n: 10 }, { fuel: 1000 })
console.log('total:', ok.result.total) // → 45

// Too little fuel: the run stops, and the recorder keeps the failure
const starved = await vm.run(agent.ast, { n: 1000000 }, { fuel: 5 })
console.log('starved run error:', starved.error?.message) // → Out of Fuel

for (const r of recorder.records({ source: 'vm' })) {
  console.log(`[${r.source}/${r.severity}]`, r.message, '(op:', r.data?.op + ')')
}
// → [vm/error] Out of Fuel (op: varAssign)
console.log('errors():', recorder.errors().length) // → 0: not a type error

transpile turns the agent's source into an AST, the data the VM runs. The failed run shows up once, with the atom that was running when the fuel ran out in data.op. It does not appear in errors(), which stays reserved for type errors.

The example reads globalThis.__tjs, not __tjs. A file that declares a typed function gets its own __tjs, and the VM does not write to that one.

Using it: audit a unit of work

The recorder works outside tests too. Note the error count, run a job, then look: anything the job produced and dropped is still there.

function parsePort(text: '8080'): 8080 {
  return Number(text)
}

// Run a job, then report the type errors it produced, even ones it ignored.
// label is required (example 'job'); job has a default.
function audited(label: 'job', job = () => 0) {
  const recorder = globalThis.__tjs // the shared recorder: every module reports here
  const before = recorder.getErrorCount()
  const result = job()
  const added = recorder.getErrorCount() - before
  const errors = added > 0 ? recorder.errors().slice(-added) : []
  console.log(`${label}: ${added ? added + ' type error(s)' : 'clean'}`)
  for (const e of errors) console.log('   ', e.message)
  return result
}

audited('good config', () => parsePort('3000')) // → good config: clean

// This job throws its first result away; the error is not lost
audited('sloppy config', () => {
  parsePort(3000)
  return parsePort('80')
}) // → sloppy config: 1 type error(s)

The second job returned a good port and would pass any test that only looked at its result. The recorder shows it also made a bad call on the way.

Two choices make this a real audit. It reads the shared recorder, so it sees errors from typed functions imported from other files, not only this one. And it compares counts instead of clearing, so it does not wipe evidence other code on the page may want. (If a job adds more than 64 errors, the oldest are gone; added still says how many there were.)

One file or the whole page

Each TJS file has its own runtime, with its own ring. Its __tjs.errors() and __tjs.clear*() see only the calls made to that file's functions. Every entry is also copied to the shared recorder, globalThis.__tjs, which is the one place that sees the whole page, VM runs included.

Copying goes one way. Clearing a file's ring leaves the shared recorder alone, and clearing the shared recorder leaves every file's ring alone.

Use __tjs to test one file's functions. Use globalThis.__tjs to ask what happened on the page.

Gotchas