Learn TypeFirst · Part 4
Decoders
Everything so far happened inside the type system, where the compiler protects you. But your app has edges: HTTP responses, request bodies, localStorage, URL params, environment variables. Data crossing an edge has a true type, and that type is unknown. TypeScript erases types at compile time — it cannot check what a server sends at 2am.
The industry's standard move is to paper over the edge:
const user = (await res.json()) as User // hope-driven development In this course as is banned, so that door is closed. The honest alternative is a decoder: a value that knows how to check, at runtime, whether an unknown blob matches a type — and gives you a typed success or a precise failure.
Decoders are values you compose
TypeFirst uses the decoders library. A Decoder<T> for a structure is built from decoders for its parts — it mirrors the type definition almost one to one:
import * as JD from "decoders"
type User = {
name: string
age: Maybe<number>
role: AccessRole // "SuperAdmin" | "Admin" | "Guest"
}
const userDecoder: JD.Decoder<User> = JD.object({
name: JD.string,
age: JD.nullable(JD.number),
role: JD.oneOf(["SuperAdmin", "Admin", "Guest"]),
}) Note the type annotation on the left. That's the TypeFirst move: the type is the source of truth, and the compiler verifies the decoder produces exactly that type. Change User and the decoder fails to compile until it matches.
Using it at the edge
const json: unknown = await res.json()
const decoded = userDecoder.decode(json)
if (decoded.ok === false) {
// precise, human-readable error:
// "Value at key 'age': Must be number"
return err("DECODE_ERROR")
}
decoded.value // ✓ User — for real this time Two properties make this transformative rather than just tidy:
- Bad data fails at the door, loudly and precisely. Without a decoder, a missing field crashes somewhere deep in a render function as
undefined is not an object, far from the cause. With one, you get "key 'age': must be number" at the exact boundary where the bad data entered. - Everything past the door is actually typed. The rest of your codebase — the part you write every day — never touches
unknownagain. The types it relies on were checked, not asserted.
The rule: decode every boundary
This is one of the six core TypeFirst principles, and ts-bedrock applies it without exception: API responses on the client, request bodies and URL params on the server (every API contract carries its decoders), environment variables at boot — the server refuses to start if process.env doesn't decode — and anything read back from storage. If data crosses a process boundary, it goes through a decoder. No exceptions, because every exception is an as wearing a trench coat.
Exercises
- Write decoders for an object with nested objects, an array of objects, and a sum type discriminated by
_t(tryJD.taggedUnion). - Decode this from a string:
{"name":"Alice","age":"30"}— note the age is a string. Write a decoder that accepts number-as-string and producesnumber(look at.transform()). - Take last part's
validateFormDataand move the validation into a decoder, so parsing and validating happen in one step at the edge.