Build Your Own Bedrock · Part 13

The client calls the same contract

TypeFirst · AgileLabs Engineering · 7 min read

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.

Does four states scale? In production it grew one more — RemoteData grew a fifth state covers the confirmation-arrives-later case. And End-to-end type-safe APIs shows this client half joined to Part 11's server half.

← Part 12: T2: never trust a database row · All posts · Part 14: A runtime in 55 lines →