Runtime Validation

Under TJS, your TypeScript parameter types become checks that run on every call.

In TypeScript, divide(a: number, b: number) is a promise the compiler checks against the calls it can see. A call with a string from a form, or a value cast with as any, compiles fine, and JavaScript then does whatever its coercion rules say: 'ten' / 2 is NaN, and NaN travels on.

TypeScript run through TJS keeps plain JavaScript behaviour by default, so converting a file changes nothing on its own. The comment /* @tjs TjsStrict */ at the top of the file opts it in to full TJS: every parameter type is checked on every call, and the rest of the TJS rules apply too (== without coercion, for example). TJS for TypeScript Programmers covers what TjsStrict switches on, and Converting a TypeScript Project describes the gentler route for a file you are migrating.

With the checks on, a bad argument is rejected where it enters: the function returns an error value instead of running its body. That value is a MonadicError, TJS's error class, a subclass of Error that is returned rather than thrown. Returning it means there is no exception to unwind and no try/catch to forget: the error travels on as an ordinary value until something checks for it. When the shared TJS runtime is installed, it also keeps a record of recent errors (__tjs.errors()) in case nothing does.

/* @tjs TjsStrict */

// In a real module: import { isMonadicError } from 'tjs-lang/runtime'
declare function isMonadicError(
  value: unknown
): value is Error & { path: string; expected?: string; actual?: string }

function divide(a: number, b: number): number {
  if (b === 0) return NaN
  return a / b
}

function half(n: number): number {
  console.log('half ran') // skipped when given an error
  return n / 2
}

// Valid calls work normally
console.log('10 / 2 =', divide(10, 2))
console.log('10 / 0 =', divide(10, 0))

// A bad argument is rejected: divide returns an error instead of running.
// tsc types the result as number, so widen it to say what can come back.
const result: number | Error = divide('ten' as any, 2)
console.log('divide("ten", 2) =', result)

if (isMonadicError(result)) {
  console.log('message:', result.message)
  console.log('path:', result.path)
  console.log('expected:', result.expected, 'actual:', result.actual)
}

// Skip the check, and the next checked function hands the error back
const halved = half(result as number)
console.log('halved is result:', halved === result)

// Operators and templates do not check: the error turns into text
console.log(`template: ${result}`)
console.log('plus one:', (result as number) + 1)

It prints:

10 / 2 = 5
10 / 0 = NaN
message: Expected number for '<source>:8:divide.a', got string
path: <source>:8:divide.a
expected: number actual: string
halved is result: true
template: MonadicError: Expected number for '<source>:8:divide.a', got string
plus one: MonadicError: Expected number for '<source>:8:divide.a', got string1

What to notice

The gotcha

Only checked parameters pass an error on. Everything else treats it as an ordinary object, and that happens in two places:

So check results at the edges of your TJS code, where they leave for a template, an operator or a library that does not know about monadic errors:

const total = divide(input.amount, input.parts)
if (isMonadicError(total)) {
  log.warn(total.message)
  return { status: 400, error: total.message }
}
render(`Each part: ${total}`)

Cost and opting out

A check costs a little on every call. On a function that does almost nothing, a checked call measures up to about twice an unchecked one; next to real work the difference is noise (Performance has the measurements). The checks are per file: a file without /* @tjs TjsStrict */ keeps plain JavaScript behaviour. In native .tjs code, a ! marker skips the check for one function; Safety & Validation covers the per-function and per-file levels.