The compiler and runtime

Validate data at runtime

Treat JSON, environment variables, form fields, and network responses as unknown until code has checked their actual shape.

Types are erased before your program runs, so nothing checks the data that arrives from outside. JSON.parse() returns whatever the text contained, no matter what type you wish it had. The honest move is to type uncertain input as unknown. That forces you to prove a useful shape before you touch any field.

Write the proof as a type predicate:

type Task = { id: number; title: string }

function isTask(value: unknown): value is Task {
  if (typeof value !== 'object' || value === null) return false
  if (!('id' in value) || !('title' in value)) return false

  return typeof value.id === 'number' && typeof value.title === 'string'
}

The checks build on each other. First rule out non-objects and null. Remember that typeof null is 'object', so the explicit null check is not optional. Then confirm the properties exist. Only then is it safe to look at their types.

Use the guard right where the data enters:

const input: unknown = JSON.parse(text)

if (!isTask(input)) {
  throw new Error('Invalid task data')
}

console.log(input.title)

After the guard, input is a Task, and the property access compiles. Before it, input.title fails with:

'input' is of type 'unknown'.

That error is the compiler holding the line until you validate.

Test it with hostile input

Feed the guard the values that break naive checks. isTask(null), isTask([]), and isTask({ id: '7', title: 'Ship' }) all return false. The string '7' is the interesting one: an annotation alone would never catch it, because annotations never look at data.

The runtime check protects the running program. The type predicate describes what is true after validation. Neither one replaces the other.

Where to validate

Keep validation close to the boundaries: JSON, network, forms, storage, and environment variables. Validate once, as the data enters, then pass the trusted Task inward. The rest of the application works with trusted values and never repeats the checks.

My rule is one guard per boundary, and no as anywhere past it. If I find an assertion deep inside the app, it usually means some data skipped its guard.

When hand-written guards stop scaling

Hand-written guards work well for a few fields. Past that, they get long and easy to get subtly wrong. For larger contracts, a schema library can produce consistent error messages and infer the TypeScript type from one definition, so the type and the check never drift apart.

Still test missing fields, wrong types, null, arrays, and oversized values. The library runs your schema, and the schema can be wrong in the same ways a guard can.

Try this: add a boolean completed field to Task and to isTask(), then check that the guard rejects the string 'false'.

Lesson completed