Build Your Own Bedrock · Part 16
Now do it in your language
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:
| Idea | File | Lines |
|---|---|---|
| A null policy | Core/Data/Maybe.ts | 52 |
| Errors as values | Core/Data/Result.ts | 126 |
| Unforgeable values | Core/Data/Opaque.ts | 47 |
| Boundary parsing | Core/Data/Decoder.ts | 74 |
| The smart-constructor recipe | Core/Data/Number/Nat.ts | 75 |
| An API contract as a value | Core/Data/Api.ts | 95 |
| Type-level URL parsing | Core/Data/UrlToken.ts | 61 |
| Async request state | Core/Data/RemoteData.ts | 39 |
| Forms that parse | Core/Data/Form/Field.ts | 79 |
| The entire frontend runtime | Web/src/Runtime/Internal.ts | 55 |
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 code — throw 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.
- 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.
- Errors as values. Build or import
Result<E, T>withok/err/mapOk/mapErr; banthrow/raisein domain code with a linter. Done when every fallible signature names its error type. - An opaque wrapper. One module, private constructor, explicit
unwrap. Done when constructing the type outside its module is a compile error. - 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. - 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.
- 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.
- Pure handlers.
(params) => Result<ErrorCode, Payload>— no request/response objects. Done when handler tests spin up no server. - Decoded rows. A row type per table, fields in domain types, every query result decoded,
unwrapon the way into SQL. Done when the DB driver's own row type appears in exactly one module. - Remote-data state. Model every async UI value as
NotAsked | Loading | Failure | Success— never aisLoadingboolean beside nullable data. Done when the UI can't render a spinner and an error at once. - 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
- Kotlin — sealed interfaces + data classes; Result as a sealed class (or Arrow's Either); opaque via a class with a
private constructorand factory in its companion; kotlinx.serialization for decoding. - Swift — enums with associated values;
Result<T, E>is in the standard library; a struct with aprivate init; throwinginit(from:)as the decoder layer. - Rust —
enumeverywhere;Resultand?are native; a newtype with a private field; serde +TryFromas smart constructor; clippy makes step 2 mechanical. - Python — unions of frozen dataclasses with a
Literaltag, checked bymypy --strict; thereturnslibrary for Result; a module-private factory for opaque; pydantic at the boundaries. - Go — errors are already values; a closed interface with an unexported method for sum types; unexported struct fields + a constructor for opaque; decode after
json.Unmarshalwith validating constructors. - Elm / Haskell / PureScript — steps 1–5 are the standard library and the culture; a module that exports the type but not its constructor is opaque. Bedrock's runtime is The Elm Architecture; you'd be porting home.
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.