Build Your Own Bedrock · Part 9
T3: an API contract is a value
Most stacks describe their APIs somewhere off to the side — an OpenAPI file, a wiki page, generated client stubs. All of these can drift from the code that actually runs. Bedrock's T3 layer takes a shorter path: an endpoint is a plain TypeScript value — a method, a route string, and three decoders — defined once in Core/Api/ and imported by both the server and the client. There is nothing to generate and nothing to sync, because there is only one artifact.
The shape of a contract
Every endpoint instantiates one generic type with six parameters:
// Core/Data/Api.ts
export type Api<
M extends Method,
Route extends string,
UrlParams extends UrlRecord<Route>,
RequestBody,
ErrorCode,
Payload,
> = {
method: M
route: Route
urlDecoder: JD.Decoder<UrlParams>
bodyDecoder: JD.Decoder<RequestBody>
responseDecoder: (
status: HttpStatus,
) => JD.Decoder<ResponseJson<ErrorCode, Payload>>
}
export type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
export type HttpStatus = 200 | 400 | 500 Read the six parameters as the six questions any endpoint must answer: which verb, which path, what's in the URL, what's in the body, what can go wrong, what comes back. Two details are doing quiet work. UrlParams extends UrlRecord<Route> ties the params record to the tokens inside the route string — that's Part 10's story. And responseDecoder is not a decoder; it's a function from HTTP status to a decoder, because what the wire carries depends on the status line.
The response is a sum type
Bedrock allows exactly three statuses, and each maps to one variant of a discriminated union — the same _t convention used everywhere in the repo:
// Core/Data/Api.ts
export type ApiError = "PAYLOAD_TOO_LARGE"
export type Ok200<D> = { _t: "Ok"; data: D }
export type Err400<E> = { _t: "Err"; code: E | ApiError }
export type InternalErr500 = { _t: "ServerError"; errorID: string }
export type ResponseJson<E, D> = Ok200<D> | Err400<E> | InternalErr500
export function responseDecoder<E, T>(
errorDecoder: JD.Decoder<E>,
dataDecoder: JD.Decoder<T>,
) {
return function (status: HttpStatus): JD.Decoder<ResponseJson<E, T>> {
switch (status) {
case 200:
return ok200Decoder(dataDecoder)
case 400:
return err400Decoder(errorDecoder)
case 500:
return internalErr500Decoder()
}
}
} Look at Err400's code field: E | ApiError. E is the endpoint's own error union — domain failures the handler chose to expose. ApiError is the platform channel: errors like "PAYLOAD_TOO_LARGE" that any endpoint can produce regardless of its logic (inside err400Decoder, the two are merged with JD.either). And a 500 never leaks a stack trace — it carries only an errorID, a hash the user can report and the server logs can be searched for.
A contract, in full
Core/Api/_Sample.ts is the annotated reference endpoint checked into the repo. Types on top, decoders below, and at the bottom the value that makes it real:
// Core/Api/_Sample.ts
export type Contract = Api<
"POST",
"/sample/:pathVar/page?queryVar=:queryVar",
UrlParams,
BodyParams,
ErrorCode,
Payload
>
export type ErrorCode = "INVALID_FUTURE_TIMESTAMP"
export type Payload = {
user: User
}
// … urlParams/body/payload/errorCode decoders …
export const contract: Contract = {
method: "POST",
route: "/sample/:pathVar/page?queryVar=:queryVar",
urlDecoder: urlParamsDecoder,
bodyDecoder: bodyParamsDecoder,
responseDecoder: responseDecoder(errorCodeDecoder, payloadDecoder),
} That contract constant is the whole point of T3. The server (Part 11) imports it to mount the route and decode incoming params; the client (Part 13) imports it to build the fetch and decode the response. Neither side re-declares anything. Change ErrorCode and every switch over it — on both sides — fails to compile until it's handled.
A real one
Production contracts look exactly the same. Here's the profile update endpoint — an authenticated one, so it instantiates AuthApi, a variant of the same 6-parameter type:
// Core/Api/Auth/UpdateProfile.ts
export type Contract = AuthApi<
"PUT",
"/update-profile",
NoUrlParams,
BodyParams,
ErrorCode,
Payload
>
export type BodyParams = {
name: Name
email: Email
newPassword: Maybe<Password>
currentPassword: Password
}
export type ErrorCode = "INVALID_PASSWORD" | "EMAIL_ALREADY_EXISTS" Notice the body fields: Name, Email, Password — opaque T1 types, not strings. The contract's bodyParamsDecoder runs the smart constructors, so by the time a handler sees the request, invalid emails and weak passwords have already been rejected at the wall with a decoder error. The handler's error union stays small and honest: the only domain failures left are "INVALID_PASSWORD" and "EMAIL_ALREADY_EXISTS".
In your language
The nearest famous cousin is Haskell's Servant, where the API is literally a type the server and client are both derived from. In Kotlin, a multiplatform shared module holding data classes plus a sealed error hierarchy gives Android and ktor the same single artifact. Swift teams do it with a shared package of Codable structs and enums with associated values for the error codes. In Rust, a shared crate of serde types imported by both the axum server and the reqwest client is idiomatic. And if your two sides are different languages entirely, this is the argument for protobuf/gRPC: the contract as one checked artifact — bedrock just gets it for free because both sides are TypeScript.