Build Your Own Bedrock · Part 16

Now do it in your language

TypeFirst · AgileLabs Engineering · 8 min read

Fifteen posts ago, Part 1 made a claim: bedrock is a few rules applied with total consistency, and you can build it yourself, in any language. You've now read every load-bearing file. Here they are in one table:

IdeaFileLines
A null policyCore/Data/Maybe.ts52
Errors as valuesCore/Data/Result.ts126
Unforgeable valuesCore/Data/Opaque.ts47
Boundary parsingCore/Data/Decoder.ts74
The smart-constructor recipeCore/Data/Number/Nat.ts75
An API contract as a valueCore/Data/Api.ts95
Type-level URL parsingCore/Data/UrlToken.ts61
Async request stateCore/Data/RemoteData.ts39
Forms that parseCore/Data/Form/Field.ts79
The entire frontend runtimeWeb/src/Runtime/Internal.ts55

About 700 lines of library code carry a full-stack production app. The runtime's whole public surface is two type aliases:

// Web/src/Runtime/Internal.ts
export type Action<S> = (state: S) => [S, Cmd<S>]
export type Cmd<S> = Array<Promise<Action<S> | null>>

What bedrock does not contain

The list of absences is as deliberate as the code. There are no classes — grep the repo. No exceptions in domain codethrow appears only inside decoders (which catch it) and at genuinely unrecoverable edges like a dead database connection. No React hooks — effects are Cmd values, so the linter bans use* imports. No DI container — handlers are functions; you pass arguments. No middleware stack — decoding params is the framework. And no mocks — a handler is (params) => Promise<Result<E, T>>, so tests call it like any function. Everything a framework would have done is done by a type instead.

The porting checklist

Ten steps, in dependency order. Each one is small; the compounding is the point. Don't skip the "done when" — it's the difference between adopting an idea and decorating with it.

  1. Sum types with a discriminant. Find your language's tagged union and make it your default for modelling. Done when matching on a case is exhaustive-checked by the compiler.
  2. Errors as values. Build or import Result<E, T> with ok/err/mapOk/mapErr; ban throw/raise in domain code with a linter. Done when every fallible signature names its error type.
  3. An opaque wrapper. One module, private constructor, explicit unwrap. Done when constructing the type outside its module is a compile error.
  4. Smart constructors. For each domain type, the triple: createX (returns Maybe), createXE (returns Result with a typed error union), xDecoder. Done when no raw primitive crosses a function boundary where a domain type exists.
  5. Decode every boundary. HTTP responses, request params, DB rows, storage, URL params, env vars — all enter as untyped data through a decoder. Done when a grep for your deserializer outside decoder modules returns nothing.
  6. The contract as a value. Method + route + param decoders + response decoder in one importable value per endpoint, in a package both sides depend on. Done when renaming a field breaks server and client in the same compile.
  7. Pure handlers. (params) => Result<ErrorCode, Payload> — no request/response objects. Done when handler tests spin up no server.
  8. Decoded rows. A row type per table, fields in domain types, every query result decoded, unwrap on the way into SQL. Done when the DB driver's own row type appears in exactly one module.
  9. Remote-data state. Model every async UI value as NotAsked | Loading | Failure | Success — never a isLoading boolean beside nullable data. Done when the UI can't render a spinner and an error at once.
  10. One state loop. A single state value; updates only via (state) => [state, effects-as-data]. Done when you can log every state transition in one place.

The same ten steps, elsewhere

In your language

If you keep one paragraph from this series, keep this one. A bedrock is not a framework you adopt; it is roughly 700 lines you write once and understand completely: a null policy, errors as values, types that can't be forged, decoders on every wall, one contract both sides import, and one loop that owns state. TypeScript was the demo, not the point. Pick the checklist up, open a fresh repo in your language, and start — as before — at the rules of the game, with ts-bedrock open in the other tab as the reference implementation.

← Part 15: Forms that parse, don't validate · All posts