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