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 declared return type is not the whole truth.
dividesays it returnsnumber, but underTjsStrictany checked function can also return aMonadicError, andtsccannot know that. Where a call can receive outside data, type the result asnumber | Errorand test it withisMonadicError, as the example does;tscthen narrows it to the error inside theifand makes you deal with the union elsewhere. - Use
isMonadicError, notinstanceof Error.instanceof Erroris also true of any ordinaryError, so it cannot tell a rejected argument from some other failure.isMonadicError(exported bytjs-lang/runtime) matches only TJS errors, and it works across separate copies of the runtime, whereinstanceofcan be false. The example declares it instead of importing it only because it runs on this page, where the TJS output already defines it. - The error says where it was rejected. The message gives the type expected, the
location of the check, and the type received. The location is
<source>:8:divide.a: the file (shown as<source>here because the example has no file name; a real module shows its own), the line wheredivideis defined, and the function and parameter. It is the function that refused the value, not the call site, so it tells you which parameter of which function rejected it. The same parts are fields on the error:path,expectedandactual. - A checked function hands an error straight back. When a function receives a
MonadicErrorfor a checked parameter, it returns that same error immediately without running its body. That is whyhalf rannever prints andhalved === resultistrue. It depends on the parameter being checked: a parameter typedanychecks nothing, so a function takinganyruns and receives the error as an ordinary value. 10 / 0is not an error. The types are right, so the check passes, and the function's own logic decides what to return. Runtime checks validate types, not business rules; those are still yours to write.
The gotcha
Only checked parameters pass an error on. Everything else treats it as an ordinary object, and that happens in two places:
- Operators and templates in your own TJS code. A template literal turns the error
into its text,
MonadicError: Expected number for ….+ 1does the same: anErrorplus a number is string concatenation, notNaN, so the result is the message with a1stuck on the end. Either string goes on into whatever page, log or database it was built for. - Code that is not TJS. A plain JavaScript library has no checks, so it receives the error as an object like any other and does whatever it does with it.
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.