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, a single...
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
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, a single...
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, a single...
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.
0. The five core ideas
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.
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.
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.
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.
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 (per the frontend-architecture skill).
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 unwrappeddata payload typed by the caller, and throws an
ApiError on any failure. Components never see envelopes or raw responses.
// CORRECT — a feature hook wraps the client, typed by the callerconst invoice = await apiClient.get<Invoice>(`/invoices/${id}`, { signal });
// WRONG — a raw fetch in a component bypasses the boundary entirelyconst res = awaitfetch(`/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.
// modules/invoice/types/invoice.schema.tsimport { z } from"zod";
exportconst 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 moneyissuedAt: z.string().datetime(),
});
exporttypeInvoice = z.infer<typeof invoiceSchema>;
// the client (or a thin per-entity wrapper) parses at the edgeconst raw = await apiClient.get<unknown>(`/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.
// shared/api-client/types.tsexportinterfaceApiSuccessEnvelope<T> {
data: T;
}
exportinterfaceApiErrorEnvelope {
error: ApiErrorBody;
}
exporttypeApiEnvelope<T> = ApiSuccessEnvelope<T> | ApiErrorEnvelope;
exportfunction isApiErrorEnvelope<T>(
e: ApiEnvelope<T>,
): e is ApiErrorEnvelope {
returntypeof e === "object" && e !== null && "error"in e;
}
exportinterfaceApiErrorBody {
code: ServerErrorCode; // machine-readable, stablemessage: string; // server message (NOT shown to users directly)fields?: Record<string, string[]>; // 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.
// shared/types/id.tsdeclareconstbrand: unique symbol;
exporttypeBrand<T, B extendsstring> = T & { readonly [brand]: B };
exporttypeInvoiceId = Brand<string, "InvoiceId">;
exporttypeCustomerId = Brand<string, "CustomerId">;
exportconst toInvoiceId = (s: string): InvoiceId => s asInvoiceId;
exportconst toCustomerId = (s: string): CustomerId => s asCustomerId;
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).
// shared/api-client/error.ts (essence)exportclassApiErrorextendsError {
readonlycode: ServerErrorCode;
readonlystatus: number; // 0 when no response (network/abort)readonlyfields?: Record<string, string[]>;
getisNetworkError() {
returnthis.status === 0;
}
gethasFieldErrors() {
return !!this.fields && Object.keys(this.fields).length > 0;
}
/** Stable key under an `errors` i18n namespace — never a raw server string. */getmessageKey() {
if (this.isNetworkError) return"network";
returnERROR_CODE_MESSAGE_KEYS[this.code] ?? "generic";
}
staticfromEnvelope(body: ApiErrorBody, status: number) {
/* server { error } */
}
staticfromNetwork(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.
// a TanStack Query mutation maps the typed error to a localized toastuseMutation({
mutationFn: (input) => apiClient.post<Invoice>("/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.
if (error.hasFieldErrors) {
for (const [field, messages] ofObject.entries(error.fields!)) {
form.setError(field asPath<FormValues>, { 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/MutationCacheonError
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.
Keep it under skills/frontend-data-contracts/SKILL.md in a public GitHub repo.
Keep the frontmatter name and high-signal description — discovery indexes match against it.