Typed API clients
View as MarkdownA typed client turns a shared contract into browser methods. Each method accepts
the inferred request and validates the response by default. A valid success
returns its body. A non-2xx response that reaches the unwrap step throws
ApiError; transport and response-validation failures stay on their original
error paths.
Create one application client module
Section titled “Create one application client module”Keep application clients in packages/frontend/src/api.ts. The module imports
browser-safe contracts and contains no handler or database code.
import { createApiClient } from "@sapporta/shared/client";import { getApiBase } from "@sapporta/frontend/platform";import { completeTaskContract } from "task-app-shared";
export const taskActionsApi = createApiClient(completeTaskContract, { baseUrl: getApiBase,});Pass getApiBase as a function. The client calls it immediately before each
request, which supports the Vite development proxy and a deployed split-origin
API without rebuilding the client object.
The contract key becomes the method name. A completion call is fully inferred:
const result = await taskActionsApi.completeTask({ params: { id: taskId }, body: {},});
console.log(result.task_id, result.event_id, result.status);There is no /api string in this call. getApiBase() supplies the API base and
the contract supplies /tasks/:id/complete, producing the mounted request URL
/api/tasks/:id/complete in same-origin development.
Preserve expected failure details
Section titled “Preserve expected failure details”ApiError.body is unknown. A proxy, stale server, or unexpected failure may
not return the declared body, so recovery begins by parsing the exported strict
feature schema rather than copying its fields into frontend code:
import { ApiError } from "@sapporta/shared/client";import { taskCompletionErrorSchema, type TaskCompletionErrorBody,} from "task-app-shared";
export type TaskActionFailure = | { status: 404; body: TaskCompletionErrorBody & { code: "TASK_NOT_FOUND" }; } | { status: 409; body: TaskCompletionErrorBody & { code: "TASK_ALREADY_COMPLETED"; }; };
export function taskActionFailure( error: unknown,): TaskActionFailure | undefined { if (!(error instanceof ApiError)) return undefined;
const parsed = taskCompletionErrorSchema.safeParse(error.body); if (!parsed.success) return undefined;
if (error.status === 404 && parsed.data.code === "TASK_NOT_FOUND") { return { status: 404, body: parsed.data }; }
if (error.status === 409 && parsed.data.code === "TASK_ALREADY_COMPLETED") { return { status: 409, body: parsed.data }; }
return undefined;}The status/code pair matters. A schema-valid 404/TASK_NOT_FOUND means the
screen’s item is stale or no longer visible. A schema-valid
409/TASK_ALREADY_COMPLETED means a prior transaction already committed. Both
branches can refetch authoritative state without revealing whether a 404 row
exists outside the request’s authority.
Returning undefined is not permission to invent a local generic conflict.
Malformed bodies, mismatched status/code pairs, shared 401/403 responses,
transport failures, response-validation failures, and unexpected errors stay on
the application’s central error path. apiProblemFromBody() remains useful for
display-only error text; its optional generic code is not an exhaustive recovery
signal.
Refresh the caches that own affected reads
Section titled “Refresh the caches that own affected reads”Keep pending and error state close to the button that owns the operation. Disable only the active command so the surrounding page stays readable.
The completion workflow updates tasks and inserts task_events. After
success, invalidate both generated-table prefixes rather than patching several
collections independently:
import { tableQueryKeys } from "@sapporta/frontend/table/query";
await Promise.all([ queryClient.invalidateQueries({ queryKey: tableQueryKeys.table("tasks"), }), queryClient.invalidateQueries({ queryKey: tableQueryKeys.table("task_events"), }),]);Apply the same invalidation after either declared stale-state branch above. A
valid 400 can be displayed, but it does not imply that committed table state
changed and does not trigger this recovery. Let every unrecognized failure reach
the screen’s central error boundary.
TGrid sessions use a separate cache. Call reloadTGridRows("tasks") or
reloadTGridRows("task_events") only when the owning screen coordinates an
affected mounted TGrid. See
Table query cache keys and ownership for the
cache-key hierarchy. The
custom workflow screens
guide owns the full mutation pattern.
Observe the inferred request
Section titled “Observe the inferred request”Start the app, complete an open task from the screen, and inspect the browser Network panel. The request should be:
POST /api/tasks/{id}/completeContent-Type: application/json
{}The response body is the declared success value:
{ "task_id": 1, "event_id": 4, "status": "completed"}For production, VITE_API_URL is a public origin, without /api. It never
contains a token or secret. Cookie-authenticated cross-origin deployments also
need matching server CORS configuration and a client credentials policy.
The client preserves wire meaning. The server route still owns abilities, row scope, and the transaction.