Build Your Own Bedrock · Part 1
The rules of the game
ts-bedrock is a production template: an Express API, a React frontend, a Postgres layer, and a shared Core that all of them import. The entire Core — every data type, every domain type, every API contract — is 2,421 lines. Go count it yourself: wc -l over Core/Data, Core/App and Core/Api.
That number is the thesis of this series. There is no framework in those lines, no clever abstraction you couldn't have written. Bedrock is four rules, applied with total consistency, to one file at a time. Over the next sixteen posts you'll build every one of those files yourself — and by the end you'll see the rules don't care what language you write them in.
Rule 1: never throw — errors are values
Domain code in bedrock does not throw. A function that can fail says so in its return type, and the caller is forced to look at both cases:
// Core/Data/Result.ts
export type Result<E, T> = ResultErr<E> | ResultOk<T>
export type ResultErr<E> = {
readonly _t: "Err"
readonly error: E
}
export type ResultOk<T> = {
readonly _t: "Ok"
readonly value: T
}
export function ok<T>(value: T): ResultOk<T> {
return { _t: "Ok", value }
}
export function err<E>(error: E): ResultErr<E> {
return { _t: "Err", error }
} The exceptions are named in the README and they are genuinely exceptional: inside decoders (which catch), and failures nobody can handle, like a dead database connection. Everything else returns Result or Maybe. The case for this rule is its own post; here it's simply law.
Rule 2: make invalid states unrepresentable
Bedrock models state as sum types, so states that shouldn't coexist can't. The frontend's root state:
// Web/src/State.ts
export type State = PublicState | AuthState
export type PublicState = {
_t: "Public" | "LoadingAuth"
route: Route
login: LoginState
}
export type AuthState = Omit<PublicState, "_t"> & {
_t: "Auth"
profile: User
updateProfile: UpdateProfileState
} There is no such thing as "logged out but holding a profile" — the type has no way to spell it. No user?: User flag to forget, no defensive if. If a bug can't be represented, it can't be shipped. (More in Impossible states.)
Rule 3: parse, don't validate
Data from outside — HTTP responses, request params, database rows, localStorage — arrives as unknown and is decoded into a typed value at the boundary, exactly once. Past the boundary, nothing is re-checked, because validated data carries its proof in its type: an Email, not a string someone hopefully checked. Opaque types and decoders each get their own part below.
Rule 4: no escape hatches
None of this survives contact with a team unless the compiler enforces it. So bedrock bans any, as, is and ! — not in a style guide, but as ESLint errors that fail the build:
// devops/eslint/strict-ts.json
{
"selector": "TSAsExpression",
"message": "Type assertions (`as`) are not allowed. …"
} A rule you can opt out of is a suggestion. These four are mechanical, which is why 2,421 lines is enough: no line has to defend itself against the others cheating.
The ladder you'll climb
Bedrock organises every type in the app into five levels, called TypeSpec: T1 core app types (Core/App, Core/Data), T2 database rows (Api/src/Database), T3 API contracts (Core/Api), T4 frontend state (Web/src/State), T5 frontend actions (Web/src/Action). Types flow downhill: everything imports Core, Core imports nothing. Part 8 walks the whole ladder.
The series
- The rules of the game — you are here
- Sign a contract with your compiler
- Maybe: a null policy in 52 lines
- Result: errors are values
- Opaque: values you can't forge
- The create/createE/decoder triple
- Parse everything that crosses the wall
- The TypeSpec ladder: five levels of types
- T3: an API contract is a value
- Type-level URL parsing
- The server fulfills the contract
- T2: never trust a database row
- The client calls the same contract
- A runtime in 55 lines
- Forms that parse, don't validate
- Now do it in your language
In your language
Nothing above is TypeScript-specific. Errors-as-values is Rust's Result and Go's error return. Invalid states unrepresentable is Kotlin sealed classes and Swift enums with associated values. Parse-don't-validate is a decoding layer you can build on serde, kotlinx.serialization, Codable or pydantic. And Elm and Haskell enforce rule 4 for free — they never had the escape hatches to ban. If you can write a tagged union in your language, you can build a bedrock.