Build Your Own Bedrock · Part 13
The client calls the same contract
In Part 11 the server mounted a contract onto Express. The frontend now imports the same value — not a generated client, not a copied interface, the identical contract object from Core/Api — and derives the entire fetch from it. Change the route, a param, an error code, or the payload shape, and both sides break at compile time together. There is exactly one generic fetch function in the whole web app; no component ever calls fetch itself.
One fetch, derived from the contract
// Web/src/Api/PublicApi.ts
export async function publicApi<
M extends Method,
Route extends string,
UrlParams extends UrlRecord<Route>,
RequestBody,
ErrorCode,
Payload,
>(
contract: PublicApi<M, Route, UrlParams, RequestBody, ErrorCode, Payload>,
urlData: UrlParams,
bodyData: RequestBody,
): Promise<ApiResponse<ErrorCode, Payload>> {
const { method, route, responseDecoder } = contract
const path = Teki.reverse(route)(toStringRecord(urlData))
return fetchE(makePath(path), {
method,
headers: jsonHeaders(new Headers()),
body: isNoBodyMethod(method) ? undefined : JSON.stringify(bodyData),
}).then(handlePublicRequest(responseDecoder))
} Read it against the server adapter and you'll see the mirror image. The route string with its :tokens is reversed into a concrete path — toStringRecord unwraps the typed UrlParams (from Part 10) into strings and Teki.reverse substitutes them in. The body is the contract's RequestBody, serialized — and since opaque types carry toJSON, JSON.stringify just works on Email and Password values. The response is handed to the contract's own responseDecoder, a function of HTTP status: 200 must decode as Ok, 400 as an Err carrying one of the endpoint's error codes, 500 as ServerError. A 200 with a malformed payload is not "close enough" — it fails the decode, on purpose.
From wire shape to Result
// Web/src/Api/PublicApi.ts
function handlePublicRequest<E, D>(
responseDecoder: (status: HttpStatus) => JD.Decoder<ResponseJson<E, D>>,
) {
return function (result: FetchResult): ApiResponse<E, D> {
const payloadM = decodeFetchResult(responseDecoder, result)
if (payloadM._t === "Err") {
return err(payloadM.error)
}
switch (payloadM.value._t) {
case "Ok":
return ok(payloadM.value.data)
case "Err":
return err(payloadM.value.code)
case "ServerError":
Logger.error(payloadM.value.errorID)
return err("SERVER_ERROR")
}
}
} Notice the errorID being logged — that's the nine-character md5 slice the server minted in Part 11, arriving at the other end of the pipe. Everything a call can produce collapses into one error union:
// Web/src/Api.ts
export type ApiError<E> =
| PublicApiError
| AuthApiError
| "SERVER_ERROR"
| "NETWORK_ERROR"
| "DECODE_ERROR"
| E
// Web/src/Api/Public/Login.ts
export type Response = ApiResponse<ErrorCode, Payload>
export async function call(params: BodyParams): Promise<Response> {
return publicApi(contract, {}, params)
}
export function errorString(code: ApiError<ErrorCode>): string {
return apiErrorString(code, (errorCode) => {
switch (errorCode) {
case "USER_NOT_FOUND":
return "User is not found."
case "INVALID_PASSWORD":
return "Password is incorrect. Please try again."
}
})
} The platform failures — network down, unparseable response, server error — are part of the type, not exceptions to remember. Each endpoint gets a thin module: a call that partially applies the generic fetch with its contract, and an errorString where apiErrorString handles the platform cases and delegates the endpoint's own codes to an exhaustive switch. Add a third code to the contract's ErrorCode union and this switch stops compiling until you've written its user-facing message.
T4: the response lives in state as RemoteData
A request isn't just success-or-failure — it's also hasn't started and in flight. Bedrock stores all four as one sum type instead of juggling isLoading and error booleans that can contradict each other:
// Core/Data/RemoteData.ts
export type RemoteData<E, T> = NotAsked | Loading | Failure<E> | Success<T>
export type NotAsked = { _t: "NotAsked" }
export type Loading = { _t: "Loading" }
export type Failure<E> = { _t: "Failure"; error: E }
export type Success<T> = { _t: "Success"; data: T }
// 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>
} This is the T4 layer of the ladder: frontend state built from T3 contract types. loginResponse starts as NotAsked, flips to Loading when the call fires, and lands on Failure or Success — and the Failure carries ApiError<LoginApi.ErrorCode>, so the view can render the exact message with errorString. The view switches on _t and the compiler makes it handle all four. "Show a spinner AND an error" is unrepresentable. Who flips these states — and where the fetch's promise actually goes — is the runtime's job, which is Part 14.
In your language
RemoteData is Elm folklore — krisajenkins/remotedata is a four-constructor union you can port anywhere you have sum types: a Kotlin sealed interface RemoteData<E, T> with four data classes, a Swift enum with associated values on failure and success, a Rust enum RemoteData<E, T> you match on. The shared-contract idea ports too: put the endpoint description in a module both binaries compile against — a Rust workspace crate shared by an axum server and a WASM client, or Kotlin Multiplatform's common source set — and route or payload drift becomes a compile error instead of a Friday incident.