Build Your Own Bedrock · Part 7

Parse everything that crosses the wall

TypeFirst · AgileLabs Engineering · 7 min read

Everything you've built so far — Maybe, Result, opaque types, smart constructors — protects code the compiler can see. But every app has walls: places where data arrives that was never type-checked because it wasn't produced by your compiled code. TypeScript will let you annotate an HTTP response as User and believe you. The annotation is a wish. At a wall, the only honest type is unknown, and the only way through is a parser.

Count your walls. ts-bedrock has six:

One library, one bridge

You don't need to hand-roll a combinator library. ts-bedrock uses nvie/decoders and adapts it to house style in one small file. Two adaptations matter. First, the library's error type isn't exported — so the file steals it with a Parameters<…> trick. Second, decoders returns its own DecodeResult, and bedrock speaks Result; fromDecodeResult is the bridge:

// Core/Data/Decoder.ts
/** Annotation type is not exported from decoders package
 * hence, we have to do a sleight of hand to trick it out
 */
export type Annotation = Parameters<typeof JD.formatInline>[0]

export function fromDecodeResult<T>(
  result: JD.DecodeResult<T>,
): Result<Annotation, T> {
  return result.ok ? ok(result.value) : err(result.error)
}

JD.formatInline takes the library's private error type as its first parameter, so Parameters<typeof JD.formatInline>[0] extracts a type the package never exported. With the bridge in place, a failed decode is just an Err carrying an Annotation — an error value like any other from Part 4, ready to be logged or mapped into a domain error.

Stringly walls

URLs and environment variables have a nastier property: everything arrives as a string. "true", not true; "42", not 42; "null", not null. Bedrock keeps a small family of string-first decoders for exactly these walls:

// Core/Data/Decoder.ts
export const booleanStringDecoder: JD.Decoder<boolean> = JD.string.transform(
  (s) => {
    switch (s) {
      case "true":
        return true
      case "false":
        return false
      default:
        throw new Error(`Invalid boolean string: ${s}`)
    }
  },
)

(That throw is the library's failure signal inside transform — decoders catches it and folds it into the DecodeResult. Your code never sees an exception.) The same pattern gives natStringDecoder for numbers in URLs, and Maybe gets a stringly variant too, because a URL can't carry a real null:

// Core/Data/Maybe.ts
export function stringMaybeDecoder<T>(
  valueDecoder: JD.Decoder<T>,
): JD.Decoder<Maybe<T>> {
  return JD.string
    .transform((s) => s.toLowerCase())
    .transform((s) => (s === "null" ? null : valueDecoder.verify(s)))
}

Its sibling maybeOptionalDecoder normalizes undefined to null, so a missing JSON key and an explicit null collapse into one Maybe — the outside world's two kinds of absence never make it past the wall.

The walls in practice

On the server, every request passes through one function that decodes query, path and body together before any handler runs:

// Api/src/Api.ts
export function decodeParams<UrlParams, RequestBody>(
  req: Express.Request,
  urlDecoder: JD.Decoder<UrlParams>,
  bodyDecoder: JD.Decoder<RequestBody>,
): Result<Annotation, UrlParams & RequestBody> {
  const urlResult = fromDecodeResult(
    urlDecoder.decode({ ...req.query, ...req.params }),
  )
  if (urlResult._t === "Err") return err(urlResult.error)

  const bodyResult = fromDecodeResult(bodyDecoder.decode(req.body))
  if (bodyResult._t === "Err") return err(bodyResult.error)

  return ok({ ...urlResult.value, ...bodyResult.value })
}

On the client, even your own localStorage is treated as hostile. Reading the auth token decodes every field and returns null if anything is off — a corrupted token means "logged out", never a crash:

// Web/src/App/AuthToken.ts
export function get(): AuthToken | null {
  const [u, a, r] = [
    userIDDecoder.decode(localStorage.getItem(userIDKey)),
    accessTokenDecoder.decode(localStorage.getItem(accessTokenKey)),
    localStorage.getItem(refreshTokenKey),
  ]
  if (u.ok == false || a.ok == false || r == null) return null

  return {
    userID: u.value,
    accessToken: a.value,
    refreshToken: unsafeToRefreshToken(r),
  }
}

Notice what the decoders produce: not string and number, but UserID, AccessToken, Nat — the opaque types from Part 5, built by the smart constructors from Part 6. A wall isn't just a checkpoint; it's where raw data is upgraded into domain values. Inside the wall, nobody re-validates anything, ever.

In your language

This is the least TypeScript-specific idea in the series — the pattern is Elm's Json.Decode, which is where decoders libraries came from. Rust gets it with serde::Deserialize plus #[serde(try_from = "…")] to run smart constructors during deserialization. Kotlin pairs kotlinx.serialization with init { require(…) } blocks. In Python, pydantic models at the boundary do the same job if you keep them out of the domain core. Go's json.Unmarshal into a private struct, followed by an explicit constructor call, is the same wall built by hand.

Companion reads: the engineering-track post Decode the outside world makes the case at length, and Learn TypeFirst, Part 4 teaches decoders from zero.

← Part 6: The create/createE/decoder triple · All posts · Part 8: The TypeSpec ladder →