Production

One endpoint, nineteen commands, eight failures

TypeFirst · AgileLabs Engineering · 9 min read

We've written about end-to-end type-safe APIs without tRPC using ts-bedrock's contract pattern: one type per endpoint, shared by server and client, with a typed error-code union. That post used a login endpoint — two fields, one happy path, the kind of example that proves nothing. This one takes the same pattern to the endpoint every SaaS eventually grows: the internal admin console, where support staff do things to customer accounts that customers cannot do themselves.

It is the worst-behaved endpoint in any product. It accumulates operations for years, every one of them dangerous, every one of them needing a different shape of input and a different set of reasons to say no.

The flagship: the account-action endpoint

Support agents act on accounts: suspend one for abuse, refund a payment, move a customer to a different plan, resend an invite, grant account credit. That's one endpoint whose request body is a nineteen-variant tagged union — each variant a command, each carrying exactly the data that command needs:

// Core/Api/Auth/AccountAction.ts
export type Contract = AuthApi<
  "POST",
  "/api/org/:orgID/account/:accountID/action",
  UrlParams, BodyParams, ErrorCode, Payload
>

export type BodyParams =
  | SuspendAccountAction       // lock an account for abuse
  | ReactivateAccountAction
  | ChangePlanAction
  | RefundPaymentAction
  | GrantCreditAction
  | ResendInviteAction
  // …13 more variants

export type ChangePlanAction = {
  _t: "CHANGE_PLAN"
  planID: PlanID
  effective: "NOW" | "NEXT_CYCLE"
}

export type RefundPaymentAction = {
  _t: "REFUND_PAYMENT"
  paymentID: PaymentID
  amount: Cents
  reason: "DUPLICATE" | "GOODWILL" | "FRAUD"
}

"Move them to the pro plan at the next cycle" is the value { _t: "CHANGE_PLAN", planID, effective: "NEXT_CYCLE" } — a compile-checked sentence. You cannot send a refund without an amount, you cannot attach a refund reason to a plan change, and you cannot typo "NEXT_CYLE", on either side of the wire, because the client builds this value from the same type the server's exhaustive switch (params._t) consumes. At runtime, each variant has its own decoder combined with JD.either, so a forged or corrupted body dies at the boundary too.

Failures are part of the contract

export type ErrorCode =
  | "PERMISSION_DENIED"
  | "ACCOUNT_NOT_FOUND"
  | "PAYMENT_NOT_FOUND"
  | "ALREADY_REFUNDED"
  | "REFUND_EXCEEDS_CAPTURED"
  | "PLAN_NOT_AVAILABLE_IN_REGION"
  | "ACCOUNT_HAS_PENDING_INVITE"
  | "PROVIDER_ERROR"

Eight business failures, as literals. The console doesn't parse error messages — it switches on the code, and the compiler guarantees every code has a UI decision: ALREADY_REFUNDED shows "another agent refunded this two minutes ago", PROVIDER_ERROR offers a retry, REFUND_EXCEEDS_CAPTURED shows the amount that is actually refundable. When the server grows a ninth failure mode, adding it to this union breaks the client build until someone decides what the agent should see. That's the whole review process for error handling, automated.

Partial success is a payload, not an error

The contract worth copying is the bulk one. Inviting forty people to an organisation is a single request, and some of those addresses already belong to a member. The type says so:

// Core/Api/Auth/Org/BulkInvite.ts
export type ErrorCode =
  | "PERMISSION_DENIED"
  | "ORG_NOT_FOUND"
  | "SEAT_LIMIT_EXCEEDED"
  | "INVALID_EMAIL_LIST"

export type Payload = {
  invited: Array<Email>
  rejected: Array<{ email: Email; reason: RejectReason }>
}

The success payload contains a failure list. "37 of 40 invited" is a state the type forces the UI to render — the classic bulk-operation bug, where the toast says "Done!" and three people silently never got an email, is unrepresentable.

The response decoder is a function of the status code

Under every contract sits one small idea that keeps the whole thing honest — the client decodes the response differently per HTTP status, and the API is only allowed three:

// Core/Data/Api/Auth.ts
export type AuthResponseJson<E, D> =
  | { _t: "AuthOk"; data: D }            // 200
  | { _t: "AuthErr"; code: E | "UNAUTHORISED" }  // 400
  | { _t: "AuthServerError"; errorID: string }    // 500

export function authResponseDecoder<E, T>(errorDecoder, dataDecoder) {
  return function (status: HttpStatus): JD.Decoder<AuthResponseJson<E, T>> {
    switch (status) {
      case 200: return authOk200Decoder(dataDecoder)
      case 400: return authErr400Decoder(errorDecoder)
      case 500: return authInternalErr500Decoder()
    }
  }
}

Even the 500 is typed: the server hashes the error message into a short errorID, shows it to the user, and logs it — so "it said error a4f09b21c" is a grep-able support ticket, without leaking a stack trace.

When the reply doesn't come back on the same wire

One more wrinkle that shows up the moment a command leaves your process. Some of these actions are fire-and-forget: the API hands the job to a worker queue and gets nothing back — the actual result arrives seconds later as a provider webhook when the refund is accepted. The command sender's type is honest about exactly that:

// Api/src/Queue/Command.ts
export type SendCommandError = "NETWORK_ERROR" | "QUEUE_ERROR"

export async function send(
  command, bodyParams, retry = 2,
): Promise<Maybe<SendCommandError>> { /* … */ }

Maybe<SendCommandError> — success is null, failure is a value, nothing throws, and there is deliberately no payload type because there is no payload. The temporal decoupling that would be a paragraph in a design doc is instead the return type. (How the UI models "accepted but not yet confirmed" is its own story — see RemoteData grew a fifth state.)

The pattern is open source. The same AuthApi contract machinery — typed error codes, status-driven response decoders — ships in ts-bedrock, and the contract walkthrough builds one from scratch.

← All posts