Build Your Own Bedrock · Part 8

The TypeSpec ladder: five levels of types

TypeFirst · AgileLabs Engineering · 7 min read

Parts 3–7 built the toolkit: Maybe, Result, Opaque, smart constructors, decoders. Before we build the rest of the app with them, you need the map. Bedrock answers a question most codebases leave to vibes — where does a type live? — with a specification called TypeSpec: every type in the application belongs to exactly one of five levels, and each level has exactly one home in the monorepo.

// The monorepo, by TypeSpec level
Core/
  Data/        T1 — data types reusable in any project
  App/         T1 — app types for this project (User…)
  Api/         T3 — API contracts (request/response)
Api/
  src/Database/  T2 — database row types (UserRow…)
  src/Api/       handlers fulfilling the T3 contracts
Web/
  src/State/     T4 — frontend state types
  src/Action/    T5 — frontend action functions

T1: the vocabulary

T1 types are the words everything else is written in. Core/Data holds the project-agnostic ones (Result, Maybe, Nat, Timestamp, Email); Core/App holds the ones that make this app this app:

// Core/App/User.ts
export type User = {
  id: UserID
  name: Name
  email: Email
}

export const userDecoder: JD.Decoder<User> = JD.object({
  id: userIDDecoder,
  name: nameDecoder,
  email: emailDecoder,
})

Twenty lines, and note what the fields are not: not string, string, string. Every field is an opaque T1 type with its own smart constructor. This one User is the same value on the server, on the wire and in the browser.

T2–T5: the same data, at each altitude

The other four levels are the shapes that same data takes at each layer of the system. T2 (Api/src/Database): UserRow mirrors the user table — but its fields are still UserID, Email, Name, because every row is decoded on the way out of the database (Part 12). T3 (Core/Api): the API contracts — params, error codes, payload — that server and client both import (Part 9). T4 (Web/src/State): what the frontend knows right now. T5 (Web/src/Action): the only functions allowed to change a T4 value (Part 14). Here's a T4 state built from the levels below it:

// Web/src/State/Login.ts
export type LoginState = {
  email: FieldString.FieldString<ErrorEmail, Email>
  password: FieldString.FieldString<ErrorPassword, Password>
  loginResponse: RD.RemoteData<ApiError<LoginApi.ErrorCode>, LoginApi.Payload>
}

Read the imports of that one type: Email and Password are T1, LoginApi.ErrorCode and LoginApi.Payload come from the T3 contract, and RemoteData is T1 again. A T4 type is assembled from the levels beneath it — it never redefines them. That's how impossible states stay impossible across an entire app: every layer spells "user" the same way.

Types flow downhill

The ladder only works if the dependencies point one way: everything imports Core; Core imports nothing (its sole dependency is the decoders library). Api and Web never import each other. And like every rule in bedrock, it's not a convention — it's a lint error:

// devops/eslint/boundaries.json
"rules": [
  {
    "from": "Core",
    "disallow": ["Api", "Web", "devops", "spec"],
    "message": "Core cannot import outside of core."
  },
  {
    "from": "Api",
    "disallow": ["Web", "devops", "spec"],
    "message": "Api cannot import outside of itself."
  },
  // … Web likewise cannot import Api
]

The payoff is a discovery rule you can teach in one sentence (the README calls it FTFC — function follows type, type follows file, file follows context): need a function on Timestamp? It's in Core/Data/Time/Timestamp.ts. Need where the login page's state changes? Web/src/Action/Login.ts. Nothing is ever "somewhere in utils".

In your language

The ladder is an architecture, not a TypeScript feature — what you need is a shared module and a mechanically-enforced import direction. In Rust, make Core a workspace crate that api and web crates depend on; Cargo forbids the reverse edge by construction. In Kotlin, a Multiplatform shared module plays the same role for an Android/JVM split. Swift: a local Swift Package. Go: a root core package, with internal/ keeping server packages out of reach of the client. Python: a core package plus import-linter contracts to fail CI on an uphill import. Elm and Haskell teams do it with package boundaries — the compiler already refuses cycles.

Why one shared spelling matters: Impossible states shows the bug class you get when two layers each define their own version of the same type.

← Part 7: Parse everything that crosses the wall · All posts · Part 9: T3: an API contract is a value →