Blog

Writing on type-driven TypeScript

Essays from building production apps with TypeFirst at AgileLabs — plus hands-on recipes and the full Learn TypeFirst tutorial series.

Engineering

Types

Making impossible states unrepresentable in TypeScript

Four booleans can represent sixteen states. Your form has five. Where do the other eleven go? Into production, usually.

Types

Opaque types: the TypeScript feature you're not using

An Email should not be a string. How one generic type and a unique symbol eliminate validation bugs for good.

Architecture

End-to-end type-safe APIs without tRPC or codegen

One contract type shared by server and client — with exhaustive, typed error codes. Rename one and both sides fail to compile.

Error handling

Never throw: Result types in production TypeScript

Exceptions are invisible in your types. Make failure a value and the compiler will make every caller handle it.

Discipline

We banned any, as, is and ! from our TypeScript

Every escape hatch is a place where your types lie. What happens when you turn all four off — permanently, with lint.

Frontend

We banned React hooks — here's what happened

No useState. No useEffect. A 50-line Elm-style runtime instead. The honest trade-offs, two years and several client projects later.

TypeFirst in production

The patterns that only show up once a system is live: state machines that grow a variant, IDs that collide, confirmations that arrive after the response — and the bugs types don't catch.

Production

A boolean can't hold a payment

A real payment has five states, not two — and isPaid: false on an authorized card is how you charge someone twice. Sum types for money.

Production

The outside world is a webhook — decode it

Every third-party webhook, queue message, env var and database row passes a decoder before a handler can touch it. The machinery, and its honest costs.

Production

One endpoint, nineteen commands, eight failures

The contract pattern at scale on the endpoint every SaaS grows: a tagged-union body of admin commands, and partial success as a payload the UI must render.

Production

Two IDs for one customer

Your customer ID never changes; the billing provider's does — and theirs isn't unique across accounts. Two bugs pre-empted by two opaque types.

Production

RemoteData grew a fifth state

HTTP 200 didn't mean "done" — confirmation arrives later over SSE. So the four-state union gained WaitingConfirm, and a race condition became a switch case.

Production

The Elm Architecture at 62,000 lines

347 files, zero hooks, zero eslint-disable, a 55-line runtime — plus the honest costs: monster action files, prop drilling, and one quarantined video player.

Production

Five bugs our types didn't catch

The other half of the ledger: what an adversarial review found in a heavily-typed codebase, and the pattern behind every gap between shape and meaning.

Build Your Own Bedrock — the series

ts-bedrock is not clever. It is a handful of FP principles applied with total consistency — the whole Core is 2,421 lines. This series rebuilds it from scratch, file by file, explaining the design decision behind each one. By the end you can build the same bedrock in any language.

Build · Part 1

The rules of the game

Four principles, no exceptions — literally. The constitution behind every file that follows, and why consistency beats cleverness.

Build · Part 2

Sign a contract with your compiler

Step 0 is configuration: strict tsconfig plus lint rules that ban any, as, is and ! mechanically, not culturally.

Build · Part 3

Maybe: a null policy in 52 lines

Not an Option class — a deliberate T | null with helpers, decoders that normalize undefined, and one named escape hatch.

Build · Part 4

Result: errors are values

The _t-discriminated union that replaces every throw in domain code — and how a Result travels over the wire.

Build · Part 5

Opaque: values you can't forge

The unique symbol trick that actually hides a value at compile time — and why unwrap and toJSON are part of the design.

Build · Part 6

The create/createE/decoder triple

One module recipe repeated everywhere: an opaque type, a typed error union, two constructors and a decoder. Nat and Password, dissected.

Build · Part 7

Parse everything that crosses the wall

HTTP, database, localStorage, URLs — every wall gets a decoder, so unknown never leaks inside.

Build · Part 8

The TypeSpec ladder: five levels of types

T1 core types, T2 rows, T3 contracts, T4 state, T5 actions — the monorepo layout where every dependency points at Core.

Build · Part 9

T3: an API contract is a value

Method, route, decoders and typed error codes in one importable value — the single source of truth both server and client fulfil.

Build · Part 10

Type-level URL parsing

Template-literal types extract :tokens from the route string, so renaming a URL param is a compile error, not a 3 a.m. page.

Build · Part 11

The server fulfils the contract

Handlers are pure functions from decoded params to Result — Express is quarantined to one file at the edge.

Build · Part 12

T2: never trust a database row

The database is outside the wall too: every row is decoded on the way out and unwrapped on the way in.

Build · Part 13

The client calls the same contract

The exact contract value the server fulfilled now drives fetch, and every response lands as typed RemoteData state.

Build · Part 14

A runtime in 55 lines

The Elm Architecture on React with zero hooks: actions are state functions, side effects are data, and the whole loop fits on one screen.

Build · Part 15

Forms that parse, don't validate

A Field holds the raw input, its parser and a memoized Result — form errors come from the same typed unions as the domain.

Build · Part 16

Now do it in your language

The payoff: a ten-step porting checklist and the idea-to-file map, from Kotlin to Go. The bedrock was never about TypeScript.

Recipes

Recipe

Type-safe file uploads with Node streams — no packages

Skip multer and multipart entirely: a streaming upload contract, a size-limited Node pipeline, and an opaque filename that can't traverse paths.

Recipe

Server-Sent Events as a typed contract

Extend the T3 contract for streaming: a typed send function on the server, decoded events on the client, each one feeding the TEA runtime as an action.

Learn TypeFirst — the series

Learn · Part 1

Immutability

Mutation is the root of bugs you can't reproduce. Rewrite five everyday functions so data never changes under you.

Learn · Part 2

Product & sum types

Count your states with cardinality, then design types where invalid combinations simply cannot exist.

Learn · Part 3

Generics

From MaybeStr and MaybeNum to Maybe<T> — write a type once and keep full safety everywhere it's used.

Learn · Part 4

Decoders

The outside world sends you unknown, not types. Decode every boundary so bad data is rejected at the door.

Learn · Part 5

Opaque types

The capstone: values that are correct by construction. Validate once with a smart constructor, trust the type forever.