Validating JSON at the Boundary

Every program has a place where untyped data becomes typed data: await res.json(), a message from a worker, a row from a database, a file a user dropped on the page. In TypeScript that place is where types stop being true, and the usual fix is a second type system (Zod, io-ts, Ajv) that you keep in step with your interfaces by hand (see JSON at the boundary is unchecked). In TJS the type is written once, and it is the one that runs.

This page has two halves. The first is for .ts files you keep writing as TypeScript. The rest is native TJS: .tjs files, where a type is declared from an example.

From TypeScript: the check is where the type is an input

TJS can compile your .ts files: fromTS (or tjs convert on the command line) turns TypeScript into TJS, which then compiles to JavaScript. A file that went through that step is converted TypeScript (see Converting a TypeScript Project). By default a converted file keeps JavaScript's behaviour and checks nothing at run time. A comment /* @tjs TjsStrict */ at the top of the file opts it into runtime checking: every parameter is then checked against its declared type. Nothing else changes; the interface, the functions and the as all stay as they were:

/* @tjs TjsStrict */

interface Order {
  id: string
  qty: number
}

function parseOrder(json: string): Order {
  return JSON.parse(json) as Order
}

function ship(order: Order): string {
  return `shipping ${order.qty} of ${order.id}`
}

console.log(ship(parseOrder('{"id": "A1", "qty": 3}')))
console.log(ship(parseOrder('{"id": "A1", "qty": "three"}')))

The first call prints shipping 3 of A1. The second prints a MonadicError instead of shipping three of A1, with the message Expected Order for '…:12:ship.order', got object (the … stands for the file name, and 12 is the line of ship).

A MonadicError is a returned value, not an exception. ship did not throw: it returned the error without running its body, and the caller can test for one with isMonadicError(result). The as Order is still a lie, exactly as it is in TypeScript; what changed is that the lie is caught at the next function that takes an Order. Without the TjsStrict comment the same file prints shipping three of A1.

Notice where the bad order is caught: in ship, not in parseOrder. A TypeScript return type converts to a type-only return, which documents the result but does not check it (the three kinds of return annotation are in the table further down). The check happens at the first function that takes an Order as a parameter. That is usually what you want, because it puts the check next to the code that depends on it, but it means the function that reads the JSON is not the one that validates it.

To validate in one place, make that place a function whose parameter is the type. In a TjsStrict file that can be a function that does nothing else:

/* @tjs TjsStrict */

interface Order {
  id: string
  qty: number
}

// The gate: its only job is to take an Order as a parameter.
function validOrder(order: Order): Order {
  return order
}

function parseOrder(json: string): Order {
  return validOrder(JSON.parse(json))
}

console.log(parseOrder('{"id": "A1", "qty": 3}'))
console.log(parseOrder('{"id": "A1", "qty": "three"}'))

The first call prints the order. The second prints a MonadicError whose path ends in validOrder.order, so the error now comes from the function that read the JSON.

One limit of this half: TypeScript's number converts to "any number", so qty: 3.5 or qty: -1 passes here. Native TJS can say "non-negative integer", as the next section shows.

In TJS: one type, used as a gate

Native TJS means a .tjs file. There, a Type block declares a named type that exists at run time. Its example (required) is a sample of the data, and every member's type is read from it; description is optional. The keys sit on separate lines with no commas. (The full syntax is in the TJS Syntax Reference, under Declarations.)

The gate is again a function that takes the type as a parameter:

Type Order {
  description: 'an order as the API sends it'
  example: { id: 'A-1', qty: +1, price: 9.99, note: '' | undefined }
}

function accept(order: Order) {
  return `${order.id}: ${order.qty} x ${order.price} = ${order.qty * order.price}`
}

const bodies = [
  '{"id": "A-1", "qty": 2, "price": 9.99}',
  '{"id": "A-2", "qty": 0, "price": 5, "note": "back-order"}',
  '{"id": "A-3", "qty": "2", "price": 9.99}',
  '{"id": "A-4", "qty": -1, "price": 9.99}',
  '{"id": "A-5", "price": 9.99}',
]

for (const body of bodies) {
  const result = accept(JSON.parse(body))
  console.log(isMonadicError(result) ? `rejected: ${result.message}` : result)
}

The output:

A-1: 2 x 9.99 = 19.98
A-2: 0 x 5 = 0
rejected: Expected Order for '…:6:accept.order', got object
rejected: Expected Order for '…:6:accept.order', got object
rejected: Expected Order for '…:6:accept.order', got object

What the example says, member by member:

Every member is required unless its example includes undefined, so A-5, which has no qty, is rejected too.

The example is not quite plain JavaScript. In a member's place it may hold a union ('' | undefined) or the name of a type (TimestampISO, further down), and those are read as types, not evaluated: '' | undefined is not a bitwise OR.

isMonadicError needs no import in a .tjs file.

The shape you actually write: fetch

Most JSON arrives from fetch. The gate goes on the line that turns the response into data. Here fetch is a local stand-in that returns canned bodies, so the example runs without a network; with the real one, loadOrder is unchanged:

Type Order {
  example: { id: 'A-1', price: 9.99 }
}

const SHIPPING = 4.5

function accept(order: Order) {
  return `${order.id}: ${order.price} plus shipping = ${order.price + SHIPPING}`
}

// A stand-in for the network: the same `ok`, `status` and `json()` a real Response has.
const responses = {
  '/orders/A-1': '{"id": "A-1", "price": 9.99}',
  '/orders/A-2': '{"id": "A-2", "price": "9.99"}',
}
async function fetch(url) {
  const body = responses[url]
  return body
    ? { ok: true, status: 200, json: async () => JSON.parse(body) }
    : { ok: false, status: 404, json: async () => ({}) }
}

async function loadOrder(url: '/orders/X') {
  const res = await fetch(url)
  if (!res.ok) return new MonadicError(`${url}: HTTP ${res.status}`)
  return accept(await res.json())
}

for (const url of ['/orders/A-1', '/orders/A-2', '/orders/A-9']) {
  const result = await loadOrder(url)
  console.log(isMonadicError(result) ? `rejected: ${result.message}` : result)
}

The output:

A-1: 9.99 plus shipping = 14.49
rejected: Expected Order for '…:7:accept.order', got object
rejected: /orders/A-9: HTTP 404

A-2 is rejected because its price arrived as a string. With a TypeScript as Order, the same code would have printed 9.994.5 as the total: + on a string concatenates.

url: '/orders/X' is an example, like every colon value in TJS: the parameter accepts any string, not only that one.

The type check covers the parsed body and nothing else. A non-2xx status, and a body that is not JSON at all (res.json() throws), are still yours to handle; the if (!res.ok) line above is the first of those. A try block with no catch turns a thrown exception into a returned MonadicError (see the syntax reference, under Errors).

Validating the result instead: :?

TJS has three return annotations. Converted TypeScript always uses :!.

annotation at build time at run time
: x a worked example: the function runs on its parameter examples and must return x not checked
:! x nothing not checked
:? x the worked example, as for : every result is checked

When the return is a named type such as Order rather than a literal, there is no worked value to compare against, so the build-time column is empty for all three: : Order and :! Order check nothing at all, and :? Order checks at run time only.

So if you would rather the reading function be the gate, annotate its return with :? Order. A bad result then comes back as a MonadicError from the function that produced it:

Type Order {
  example: { id: 'A-1', qty: +1 }
}

function parseOrder(json: '{"id": "A-1", "qty": 1}'):? Order {
  return JSON.parse(json)
}

const good = parseOrder('{"id": "A-2", "qty": 3}')
console.log(JSON.stringify(good))
const bad = parseOrder('{"id": "A-2", "qty": "three"}')
console.log(isMonadicError(bad) ? bad.message : bad)

The output:

{"id":"A-2","qty":3}
Expected Order for '…:5:parseOrder:return', got object

The path ends in parseOrder:return (a colon, where a parameter would be .json), so it names the function that read the data. With : Order or :! Order the second call returns the bad object unchecked.

The parameter example '{"id": "A-1", "qty": 1}' documents the input and says it is a string. Because the return is a named type, it is not run through the function at build time, so a wrong example ('{"qty": "x"}') would not be caught there.

Checking without calling: check and strip

A Type is also a value you can ask directly. check answers true or false. strip returns a copy with only the members the type declares:

Type Order {
  example: { id: 'A-1', qty: +1 }
}

const incoming = JSON.parse('{"id": "A-1", "qty": 2, "isAdmin": true}')

console.log(Order.check(incoming))
console.log(JSON.stringify(Order.strip(incoming)))

This prints true, then {"id":"A-1","qty":2}.

check is true even though the payload carries a key the type never mentioned. That is deliberate: TJS types describe what must be present, and like TypeScript's they let extra members through. If extra members could do harm downstream (an isAdmin flag written straight into a database row is the classic case), strip before you store.

Use check where you want to branch on the answer yourself, for example to filter a list or pick a fallback. Use a parameter gate where a bad value should stop the work and travel back to the caller as an error.

Handing the type to other tools: toJSONSchema

If you are coming from Zod or Ajv, you probably also publish your schemas: to an API gateway, an OpenAPI document, or a service written in another language. A Type exports standard JSON Schema:

Type Order {
  description: 'an order as the API sends it'
  example: { id: 'A-1', qty: +1, price: 9.99, note: '' | undefined }
}

console.log(JSON.stringify(Order.toJSONSchema()))

// The same schema, open to extra members the way Order.check is:
const open = { ...Order.toJSONSchema(), additionalProperties: true }
console.log(open.additionalProperties)

The first line of output, reformatted here for reading:

{
  "type": "object",
  "properties": {
    "id": { "type": "string" },
    "qty": { "type": "integer", "minimum": 0 },
    "price": { "type": "number" },
    "note": { "type": "string" }
  },
  "required": ["id", "qty", "price"],
  "additionalProperties": false
}

Each member reads the way the example did: +1 becomes an integer with minimum: 0, the decimal point becomes number, and the optional note is left out of required. The description is not part of the export; add it to the object yourself if your tools want it.

One difference to know before you publish it: the exported schema is closed (additionalProperties: false). It describes exactly the members the type declares, the same set strip keeps, so a JSON Schema validator rejects the isAdmin payload from the previous section, which Order.check accepts. There is no option to change that. If the gateway should be as lenient as your code, override the keyword on the returned object, as the last two lines of the example do (they print true).

Replacing an existing Zod schema with a Type is its own chapter: From Zod to TJS.

The gotchas

The error names the type, not the member. When a whole object fails a named type, the message is Expected Order for '…accept.order', got object. It tells you which parameter and which type; it does not say which member was wrong. For an inline shape the path does reach the member. When you need it, write the members inline on the gate:

Type Order {
  example: { id: 'A-1', qty: +1 }
}

function accept(order: Order) {
  return `${order.qty} of ${order.id}`
}

// Same members, written inline: the error now names the member.
function acceptShape(order: { id: 'A-1', qty: +1 }) {
  return `${order.qty} of ${order.id}`
}

const body = JSON.parse('{"id": "A-1", "qty": "2"}')
console.log(accept(body).message)
console.log(acceptShape(body).message)

The output:

Expected Order for '…:5:accept.order', got object
Expected non-negative-integer for '…:10:acceptShape.order.qty', got string

Or log the payload next to the error.

JSON has no Date. A TypeScript interface that says createdAt: Date is wrong about every value JSON.parse returns: the field is a string. Declare it as what arrives and convert it after the check.

Native TJS does not allow new Date(), because a Date is mutable and its meaning depends on the clock and time zone of the machine it runs on. The replacement is Timestamp: epoch milliseconds, a plain number, with helpers such as Timestamp.parse (ISO string to number), Timestamp.addDays and Timestamp.iso (number to ISO string). TimestampISO is the type of an ISO 8601 string, which is what most APIs send. Unlike isMonadicError, Timestamp and TimestampISO are imports, not globals:

import { Timestamp, TimestampISO } from 'tjs-lang'

Type Order {
  example: { id: 'A-1', placedAt: TimestampISO }
}

function accept(order: Order) {
  const placed = Timestamp.parse(order.placedAt)
  const due = Timestamp.addDays(placed, 3)
  return `${order.id} due ${Timestamp.iso(due)}`
}

const bodies = [
  '{"id": "A-1", "placedAt": "2025-11-06T09:30:00Z"}',
  '{"id": "A-2", "placedAt": "last Tuesday"}',
  '{"id": "A-3", "placedAt": 1762421400000}',
]

for (const body of bodies) {
  const result = accept(JSON.parse(body))
  console.log(isMonadicError(result) ? `rejected: ${result.message}` : result)
}

A-1 is due 2025-11-09T09:30:00.000Z. A-2 is rejected because "last Tuesday" is not an ISO timestamp, and A-3 because a number is not a string: if your API sends epoch milliseconds, declare the member as a number instead. When a library insists on a real Date, LegacyDate(x) makes one; it is the named escape for new Date(x) (see "The rules of .tjs" in the syntax reference).

A type-only return is not a check. As in the table above: :! on a return documents the result and nothing more, and so does : when the return is a named type. Use :? Order on the function that reads the data, or pass the data to a function whose parameter is Order.

Converted code without TjsStrict checks nothing. As the first section says, a converted file keeps JavaScript's behaviour until it is marked TjsStrict or graduated (turned into a native .tjs file, the second step in Converting a TypeScript Project).