Learn TypeFirst · Part 4

Decoders

TypeFirst · AgileLabs Engineering · 7 min read

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:

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

Do the real thing: Learn/Exercise/4-Decoder.ts, with decoders.cc as reference. Then the finale: Part 5 — Opaque types, where decoded data earns a type that proves it.

← All posts