--- name: frontend-data-contracts description: "A portable, framework-agnostic discipline for type safety at the network edge of any React or React Native app." risk: critical source: https://github.com/stareezy-1/frontend-architecture-skill/tree/main/skills/frontend-data-contracts source_repo: stareezy-1/frontend-architecture-skill source_type: community date_added: 2026-07-01 license: MIT license_source: https://github.com/stareezy-1/frontend-architecture-skill/blob/main/LICENSE --- # Frontend Data Contracts (typed network boundary) ## When to Use Use this skill when you need a portable, framework-agnostic discipline for type safety at the network edge of any React or React Native app. Establishes one typed API client as the single fetch boundary, a parse-don't-validate rule that turns wire JSON into trusted domain types before it enters the app, runtime parsing, normalized errors and branded identifiers. > Portable skill — readable by Claude Code, OpenCode, Codex, Cursor, Windsurf, and others. > This skill describes a **discipline at the network edge** — one client, one envelope, one error > type, validated types — not a state library or a styling system. It pairs with the > **frontend-architecture** skill (the client lives in `shared/api-client/`) and is the foundation > the **frontend-optimistic-mutations** skill builds on. The goal: the moment data crosses from the network into the app, it stops being `any`-shaped wire JSON and becomes a **trusted, typed domain value** — or it becomes a **single, typed error**. There is exactly one place this transformation happens, and nothing untyped escapes it. --- ## Example contract The snippets marked “essence” are architecture sketches with omitted project dependencies and methods, not a complete client. A generic `get` type parameter is only a compile-time assertion: it cannot establish runtime validity. Require the actual schema parse before treating a value as an Invoice, normalize parser failures into the project error type, and keep authorization independent of both schema validation and branded IDs. Bind adapter behavior to the installed library version; do not assume an unsupported per-query callback exists. ## 0. The five core ideas 1. **One client is the only fetch boundary.** A single typed `apiClient` wraps `fetch`. Components and hooks never call `fetch`/`axios` directly — the boundary is enforceable in review and lint. 2. **Parse, don't validate.** Wire JSON is parsed into domain types at the boundary. After the client returns, the value is trusted everywhere downstream — no defensive `?.` chains, no re-checking shapes in components. 3. **One envelope.** Every response is `{ data }` on success or `{ error }` on failure. The client unwraps `data` and throws on `error`, so callers receive the payload directly or a typed throw. 4. **One normalized error type.** Server error envelope, non-2xx status, malformed body, network failure, and abort all become a single `ApiError` with a machine code, status, and optional per-field errors. Callers handle one shape. 5. **Identifiers are branded.** Domain IDs are nominal types (`InvoiceId`, `CustomerId`) so the compiler rejects passing one where another is expected — the most common silent bug in data-heavy UIs. --- ## 1. Directory layout The boundary is one folder in `shared/` (per the frontend-architecture skill). ``` src/shared/api-client/ ├── index.ts ← barrel: apiClient, ApiError, types ├── client.ts ← the fetch wrapper: buildUrl, headers, parse, verbs ├── config.ts ← base URL resolution, default headers ├── error.ts ← the ApiError class + code→message-key mapping ├── types.ts ← envelope types, HttpMethod, RequestOptions, field errors └── client.test.ts ← boundary behavior tests (envelope, errors, network) ``` Domain entity types and their **schemas** live with their feature module (`modules/{feature}/types/`) or a shared contract package; the client is generic over `T`. --- ## 2. One client, the only fetch boundary Every verb returns the **unwrapped** `data` payload typed by the caller, and **throws** an `ApiError` on any failure. Components never see envelopes or raw responses. ```ts // shared/api-client/client.ts (essence) export const apiClient = { get(path: string, options?: RequestOptions): Promise { return request("GET", path, undefined, options); }, post(path: string, body?: unknown, options?: RequestOptions): Promise { return request("POST", path, body, options); }, patch(path: string, body?: unknown, options?: RequestOptions): Promise { /* … */ }, put(path: string, body?: unknown, options?: RequestOptions): Promise { /* … */ }, delete(path: string, options?: RequestOptions): Promise { /* … */ }, } as const; export type ApiClient = typeof apiClient; ``` ```ts // CORRECT — a feature hook wraps the client, typed by the caller const invoice = await apiClient.get(`/invoices/${id}`, { signal }); // WRONG — a raw fetch in a component bypasses the boundary entirely const res = await fetch(`/api/invoices/${id}`); // untyped, unhandled errors, no envelope ``` **Hard rules:** - No `fetch`/`axios`/`XMLHttpRequest` outside `shared/api-client/` — enforce with an ESLint `no-restricted-imports`/`no-restricted-globals` rule. - The client is **framework-free**: no toasts, no router, no React. Side effects (toasts, redirects) live in the query layer's `onError` (see §6). - Pass `AbortSignal` through `RequestOptions` so the query layer can cancel (wired by TanStack Query). --- ## 3. Parse, don't validate (the boundary transform) "Validate" leaves you with the same untyped value and a boolean. "Parse" returns a **new, typed value** — so downstream code is guaranteed correct by the type system. Run a schema parse at the boundary; after that, the value is trusted. ```ts // modules/invoice/types/invoice.schema.ts import { z } from "zod"; export const invoiceSchema = z.object({ id: z.string().transform(toInvoiceId), // brand it (see §5) number: z.string(), status: z.nativeEnum(InvoiceStatus), total: z.number().int(), // minor units — never float money issuedAt: z.string().datetime(), }); export type Invoice = z.infer; ``` ```ts // the client (or a thin per-entity wrapper) parses at the edge const raw = await apiClient.get(`/invoices/${id}`, { signal }); return invoiceSchema.parse(raw); // throws on contract drift → surfaces as a typed failure ``` **Why this matters:** a backend that renames a field or sends a `null` it shouldn't is caught **at the boundary**, with a clear error, instead of producing `undefined` three components deep where the stack trace is useless. Components downstream never write `invoice?.total ?? 0` defensively. > Validation library is your choice — **Zod**, **Valibot**, **ArkType**, **io-ts**. The rule is > constant: a parse step converts `unknown` wire data into a typed domain value at one boundary. --- ## 4. One response envelope Mirror the backend's single envelope in the client and unwrap it once. ```ts // shared/api-client/types.ts export interface ApiSuccessEnvelope { data: T; } export interface ApiErrorEnvelope { error: ApiErrorBody; } export type ApiEnvelope = ApiSuccessEnvelope | ApiErrorEnvelope; export function isApiErrorEnvelope( e: ApiEnvelope, ): e is ApiErrorEnvelope { return typeof e === "object" && e !== null && "error" in e; } export interface ApiErrorBody { code: ServerErrorCode; // machine-readable, stable message: string; // server message (NOT shown to users directly) fields?: Record; // per-field validation errors } ``` The parse step handles every shape: `204 No Content` → `undefined`; `{ error }` → throw; non-2xx with no well-formed envelope → synthesize an error; `{ data }` → return `data`. The caller only ever sees a typed payload or a throw. --- ## 5. Branded (nominal) identifiers Strings are interchangeable; domain IDs are not. Brand them so the compiler stops you passing a `CustomerId` where an `InvoiceId` is required. ```ts // shared/types/id.ts declare const brand: unique symbol; export type Brand = T & { readonly [brand]: B }; export type InvoiceId = Brand; export type CustomerId = Brand; export const toInvoiceId = (s: string): InvoiceId => s as InvoiceId; export const toCustomerId = (s: string): CustomerId => s as CustomerId; ``` ```ts function loadInvoice(id: InvoiceId) { /* … */ } loadInvoice(customerId); // ❌ compile error — exactly the bug you want caught loadInvoice(invoiceId); // ✅ ``` Brand IDs at the parse boundary (§3) so every ID in the app is already nominal. The runtime cost is zero — brands erase at compile time. --- ## 6. One normalized error type Collapse every failure mode into a single `ApiError` so callers handle one shape. It carries a machine `code`, the HTTP `status`, optional per-field errors, and a stable key for localized messages (it does **not** localize — that's the UI's job). ```ts // shared/api-client/error.ts (essence) export class ApiError extends Error { readonly code: ServerErrorCode; readonly status: number; // 0 when no response (network/abort) readonly fields?: Record; get isNetworkError() { return this.status === 0; } get hasFieldErrors() { return !!this.fields && Object.keys(this.fields).length > 0; } /** Stable key under an `errors` i18n namespace — never a raw server string. */ get messageKey() { if (this.isNetworkError) return "network"; return ERROR_CODE_MESSAGE_KEYS[this.code] ?? "generic"; } static fromEnvelope(body: ApiErrorBody, status: number) { /* server { error } */ } static fromNetwork(cause: unknown) { /* offline / CORS / abort → status 0 */ } } ``` ### 6.1 Where side effects live The client throws; the **query layer** decides what the user sees. Keep toasts/redirects out of the client. ```ts // a TanStack Query mutation maps the typed error to a localized toast useMutation({ mutationFn: (input) => apiClient.post("/invoices", input), onError: (error: ApiError) => notifyError(error), // looks up error.messageKey in i18n }); ``` ### 6.2 Per-field errors → form fields Server validation (`fields`) maps straight onto form-field errors — one place, typed. ```ts if (error.hasFieldErrors) { for (const [field, messages] of Object.entries(error.fields!)) { form.setError(field as Path, { message: messages[0] }); } } ``` **Hard rules:** - Never `throw new Error(string)` from the data layer — always `ApiError` with a `code`. - Never show `error.message` (a server/dev string) directly to users — resolve `messageKey` through i18n. - A 2xx with an unparseable body is a **contract violation** → throw `INVALID_RESPONSE`, don't silently return `undefined`. --- ## 7. Library adapters The discipline is constant; the data-fetching library only changes where `onError`/parsing hangs. | Library | Where the client is called | Where `ApiError` is handled | | -------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | **TanStack Query** | `queryFn`/`mutationFn` call `apiClient.*` | `onError` per query/mutation, or a global `QueryCache`/`MutationCache` `onError` | | **RTK Query** | `baseQuery` wraps `apiClient`, parses + returns `{ data }` or `{ error }` | `transformErrorResponse` → `ApiError`; handle in component or middleware | | **SWR** | `fetcher = (key) => apiClient.get(key)` | `onError` in `SWRConfig` or per-hook | | **Plain fetch hooks (RN)** | a `useAsync` wrapper calls `apiClient.*` | try/catch sets a typed error state | For **React Native**, the client is unchanged — `fetch` and `AbortController` exist in RN. Only `credentials: "include"` (cookie auth) may need swapping for a token header depending on your auth. --- ## 8. Conventions checklist (enforce in review) - [ ] Exactly one `apiClient` in `shared/api-client/`; no `fetch`/`axios` anywhere else (lint-enforced). - [ ] The client is framework-free — no toasts, router, or React inside it. - [ ] Responses are parsed into typed domain values at the boundary (parse, don't validate). - [ ] One `{ data } / { error }` envelope, unwrapped once in the client. - [ ] Every failure becomes one `ApiError` (code + status + optional fields); no bare `throw new Error`. - [ ] Domain IDs are branded; IDs are branded at the parse boundary. - [ ] `error.messageKey` resolves through i18n — server `message` is never shown to users. - [ ] Per-field server errors map onto form fields via the typed `fields` map. - [ ] `AbortSignal` flows through `RequestOptions` for cancellation. - [ ] A 2xx with a malformed body throws a contract-violation error, not `undefined`. --- ## 9. How to apply this skill **Adding the boundary to a project:** create `shared/api-client/` with `client.ts`, `error.ts`, `types.ts`, `config.ts`. Add the lint rule banning `fetch`/`axios` elsewhere. Define your envelope to match the backend, and your `ApiError` codes. **Adding a new entity:** define its schema (Zod/Valibot) and `z.infer` type in the feature module, brand its ID at parse time, and wrap `apiClient` in typed query/mutation hooks — never call the client from a component. **Debugging "undefined three components deep":** add/repair the boundary parse so contract drift fails loudly at the edge with a typed error, instead of leaking `undefined` downstream. **Reviewing the data layer:** run the checklist in §8. The highest-value catches are raw `fetch` in components (boundary bypass) and `throw new Error(string)` from the data layer (untyped failures). --- ## Publishing / installing this skill This skill follows the Anthropic `SKILL.md` format and is portable across agents. 1. Keep it under `skills/frontend-data-contracts/SKILL.md` in a public GitHub repo. 2. Keep the frontmatter `name` and high-signal `description` — discovery indexes match against it. 3. Install with: `npx skills add / --skill "frontend-data-contracts"`. 4. Non-`SKILL.md` agents can be pointed here from `AGENTS.md` / `CLAUDE.md`; Kiro can mirror it as a steering file. ## Limitations - Use this skill only when the task clearly matches its upstream source and local project context. - Verify commands, generated code, dependencies, credentials, and external service behavior before applying changes. - Do not treat examples as a substitute for environment-specific tests, security review, or user approval for destructive or costly actions.