What the generator refuses
It fails at client.init() — not on a request in production — naming the
endpoint and the path that caused it, and pointing at document. Guessing would
produce a query the server rejects at runtime, which is strictly worse.
| Schema | Why |
|---|---|
z.union([...]) of objects | needs inline fragments (... on Type), and the schema does not say which types |
z.record(...) | GraphQL has no way to ask for "every field" |
| recursive schemas | a query must be finite; the depth is a decision only you can make |
variables with no root | there is no single field to attach the arguments to |
Give those endpoints an explicit document; everything else about them still
works, including validation against response.
Errors
GraphQL error codes are mapped onto typefetch's shared ErrorKind, so one
global handler covers every transport:
client.onError((error) => {
if (error.kind === 'unauthenticated') redirectToLogin()
})fires for a GraphQL UNAUTHENTICATED exactly as it does for an HTTP 401.
extensions.code | kind |
|---|---|
UNAUTHENTICATED | unauthenticated |
FORBIDDEN | permission_denied |
BAD_USER_INPUT, GRAPHQL_VALIDATION_FAILED, GRAPHQL_PARSE_FAILED | invalid_argument |
NOT_FOUND | not_found |
TOO_MANY_REQUESTS | resource_exhausted |
INTERNAL_SERVER_ERROR | internal |
SERVICE_UNAVAILABLE | unavailable |
Both response media types are handled: the legacy application/json (errors
delivered inside a 200) and application/graphql-response+json (errors with a
real 4xx/5xx). extensions.http.status is honoured when present.
Typed error bodies
errors is keyed by extensions.code for a GraphQL endpoint, and
isContractError narrows off it:
errors: {
UNAUTHENTICATED: z.object({ code: z.literal("UNAUTHENTICATED"), realm: z.string() }),
}
try {
await api.user.get({ id });
} catch (e) {
if (isContractError(contracts.user.get, e, "UNAUTHENTICATED")) {
e.data.realm; // fully typed
}
}Partial data
A response carrying both data and errors is the case every GraphQL client
gets wrong in one direction or the other.
errorPolicy: "none"(default) — throw. A partially-failed response is a failed one, and silently returning half a result is how anullreaches a UI three screens from its cause.errorPolicy: "all"— resolve the data and hand the errors toonPartialErrors, so the return type staysPromise<T>and nothing is dropped.
graphqlTransport({
url: '…',
errorPolicy: 'all',
onPartialErrors: (errors, { endpointId }) => log.warn(endpointId, errors),
})