Type-Safe API Calls Without a Code Generator
Photo: Unsplash
The standard advice for typing your API layer is to run a code generator over an OpenAPI or GraphQL schema and import the result. When you have a well-maintained schema, that is great. But plenty of teams do not. The backend is a small Express service, the schema drifts from reality, or you simply do not want a build step that regenerates thousands of lines on every change. So people fall back to the worst option: casting fetch responses with as and hoping.
There is a middle path that gives you real, trustworthy types without any generator. The key insight is that the type returned by your network layer is only as honest as the runtime check behind it. Once you validate the response at the boundary, you can infer the static type from that validator, and everything downstream is genuinely safe. No schema sync, no generated files, no lies.
Why as is not type safety
Start by naming the problem. response.json() returns Promise<any>, and any infects everything it touches. Casting it does not check anything; it just silences the compiler.
interface User {
id: string;
email: string;
}
const res = await fetch("/api/user/1");
const user = (await res.json()) as User; // a hope, not a guarantee
console.log(user.email.toUpperCase()); // crashes if email is missingIf the server returns { id: 1 } with a numeric id and no email, this code compiles cleanly and explodes at runtime. The as User is a promise you made to the compiler that nothing verifies. Because res.json() is typed as any (see the MDN Response.json() reference), the compiler cannot help you here even in principle. Static types describe values the compiler can see; a network payload arrives at runtime, after the type checker has gone home.
Validate once, at the boundary
The fix is to check the shape of the data exactly where it enters your program, and to make that check the source of truth for the type. You can do this with a hand-written type guard and no dependencies at all.
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
typeof (value as Record<string, unknown>).id === "string" &&
typeof (value as Record<string, unknown>).email === "string"
);
}
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/user/${id}`);
const data: unknown = await res.json();
if (!isUser(data)) throw new Error("Invalid user payload");
return data; // narrowed to User by the guard
}Two things make this honest. First, data is typed unknown, not any, so the compiler forces you to prove its shape before using it. Second, the value is User return type is a user-defined type guard: after the if, the compiler narrows data to User because the runtime check actually ran. Now the type and the value agree, and getUser can promise Promise<User> truthfully.
The rule that makes everything work: type your raw payload as unknown, never any. unknown forces a check before use; any waves it through. This one habit eliminates the most common class of runtime type errors.
Scale it with a schema library
Hand-written guards are fine for a few endpoints, but they get tedious and drift from the type. Schema validation libraries solve this by letting you declare the shape once and deriving both the runtime check and the static type from that single declaration. The pattern, not any specific library, is what matters: define a schema, infer the type from it.
// Pseudocode in the style of common schema libraries.
const UserSchema = object({
id: string(),
email: string(),
createdAt: string(),
});
type User = Infer<typeof UserSchema>; // type derived from the schema
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/user/${id}`);
return UserSchema.parse(await res.json()); // throws on mismatch
}The win is that the type and the validator can never disagree, because the type is generated from the validator at compile time via inference. Add a field to the schema and the type updates automatically. There is no separate declaration to keep in sync, which is exactly the failure mode that code generators try to solve with tooling and this approach solves with typeof and inference. The mechanics rely on the same inference features documented in the TypeScript handbook.
A small typed client
Wrap the pattern in a reusable helper and your whole data layer becomes safe with very little code. The helper takes a validator and returns parsed, typed data.
async function fetchJson<T>(
url: string,
validate: (value: unknown) => T,
): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return validate(await res.json());
}
// Every call site gets a precise type from the validator it passes.
const user = await fetchJson("/api/user/1", parseUser);
const posts = await fetchJson("/api/posts", parsePostArray);The generic T is inferred from the validator's return type, so callers never write type arguments. Each endpoint supplies its own validator, and the result type follows automatically. You have end-to-end type safety, a single place that handles HTTP errors, and zero generated files. If your backend does expose a stable OpenAPI document, a generator is still a fine choice; this approach is for the very common case where it does not, or where you want validation you actually control.
Handling the unhappy paths
Type safety at the boundary also forces you to confront errors you used to ignore. A validate that throws on a malformed payload turns a silent downstream crash into a clear, catchable failure at the network edge. Pair it with a check on res.ok for HTTP status, and optionally return a discriminated union of success and failure instead of throwing, so callers handle both cases explicitly. The boundary is the one place where runtime reality and your static types meet, so it is the right place to spend your defensive effort.
Takeaways
- Casting
fetchresults withasprovides zero runtime safety; it only silences the compiler. - Type raw payloads as
unknownso the compiler forces a check before use. - A user-defined type guard (
value is T) narrows validated data to a real type. - Schema libraries let you declare a shape once and infer the static type with
typeof, so they cannot drift. - A small generic
fetchJsonhelper gives every call site a precise type with no type arguments. - Use generators when you have a stable schema; validate-at-the-boundary covers the many cases where you do not.

