TL;DR
Build type-safe APIs in TypeScript with generic fetch wrappers, discriminated union responses, and runtime type guards.
Key concepts
- type-safe API TypeScript
- typed fetch wrapper
- API response types
- TypeScript API patterns
Type-Safe APIs
Every web application eventually talks to an API. The problem is that network responses arrive as raw JSON — untyped blobs that TypeScript knows nothing about. Without discipline, you end up casting everything to any, losing all the benefits of the type system exactly where bugs are most likely to appear.
Type-safe APIs are about drawing a clear boundary between the untyped world of network I/O and the typed world of your application logic. You define what a response should look like, validate it at the boundary, and let TypeScript enforce correctness everywhere else. When a field is renamed in the backend or a nullable value appears unexpectedly, you catch it immediately — not in a 3am production incident.
This lesson covers the core patterns: typing response shapes, building a generic fetch wrapper, modeling success and failure with discriminated unions, and writing runtime type guards to validate data you cannot fully trust.
How this lesson runs
Every fence below runs on this page. There is one thing to be honest about, though: the runner has no network (the sandbox is offline), so nowhere in this lesson do we call the real fetch(). Instead each example uses a simulateFetch helper or a hardcoded payload that stands in for a response response.json() would produce. In a real app you would swap the simulated call for await fetch(url).then((r) => r.json()) — the typing patterns (generic wrappers, discriminated-union results, runtime guards) are identical either way, and they are exactly what this lesson is about. The runner also strips types before executing, so the type guards you write here are doing real runtime work, not compile-time decoration.
Defining Response Shapes
The first step is replacing any with an interface that describes exactly what the API returns. This is not just documentation — TypeScript uses these shapes to flag every incorrect property access across your entire codebase.
interface User {
id: number;
name: string;
email: string;
role: "admin" | "editor" | "viewer";
}
interface Post {
id: number;
title: string;
authorId: number;
publishedAt: string | null;
}
// Simulate an API response arriving as parsed JSON
const rawUserResponse: unknown = {
id: 1,
name: "Alice",
email: "alice@example.com",
role: "admin",
};
// A cast only *asserts* the shape — it checks nothing at run time.
// This is the starting point, not the destination: "Runtime Type Guards"
// below replaces it with a check that actually verifies the payload.
const user = rawUserResponse as User;
console.log(`User: ${user.name} (${user.role})`);
// TypeScript error if you try: user.password — property doesn't exist
console.log(`Email: ${user.email}`);
Defining the shape is the part worth keeping: once a value is a User, every access to user.name, user.role, or any other property is checked by the compiler. But notice what the cast itself does — nothing. As 20-zod-and-validation showed, as is a promise to the compiler, not a check: if the server sends role: "superuser" or omits email, this code compiles clean and fails at run time. The shape is right; the entry is still unguarded. That is the gap the rest of this lesson closes.
A Generic Fetch Wrapper
Repeating that cast at every single API call has the obvious cost — duplication — and a worse one: each copy is a separate unverified promise to the compiler. A generic wrapper fixes the duplication first, handling the fetch, parsing the JSON, and returning a typed result so callers never have to think about unknown again. It does not, on its own, fix the verification gap.
// Simulated async fetch — in a real app this would call fetch()
async function simulateFetch<T>(data: T, shouldFail = false): Promise<T> {
await new Promise((resolve) => setTimeout(resolve, 10));
if (shouldFail) throw new Error("Network request failed");
return data;
}
async function apiGet<T>(simulatedData: T): Promise<T> {
// In production: const response = await fetch(url)
// const data = await response.json()
const data = await simulateFetch(simulatedData);
// Still a cast: the wrapper centralizes the SHAPE, one place instead of
// every call site, but it verifies nothing about the payload. "Runtime
// Type Guards" below is where the checking lands.
return data as T;
}
interface Product {
id: number;
name: string;
price: number;
inStock: boolean;
}
async function main() {
const product = await apiGet<Product>({
id: 42,
name: "Mechanical Keyboard",
price: 149.99,
inStock: true,
});
// TypeScript knows product.price is a number
console.log(`${product.name}: $${product.price.toFixed(2)}`);
console.log(`In stock: ${product.inStock ? "Yes" : "No"}`);
}
main();
The generic <T> parameter travels from the call site all the way through the wrapper. When you call apiGet<Product>(...), TypeScript infers that the return type is Promise<Product> — giving you full autocompletion and error checking on everything you do with the result.
Modeling Success and Failure
Real APIs fail. A common mistake is throwing exceptions for API errors and leaving callers to catch them or not. A more explicit approach is a discriminated union that forces every caller to handle both cases. This is the same Result pattern you built in 09-error-handling ({ ok: true; value } | { ok: false; error }) — real-world APIs and libraries conventionally name the fields success/data instead of ok/value, exactly the shape zod's safeParse returns (you saw it in 20-zod-and-validation). Only the field names change; the discriminant-narrows-then-you-read-the-payload discipline is identical.
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: string; status: number };
interface Article {
id: number;
title: string;
body: string;
}
// Returns a result instead of throwing
async function fetchArticle(id: number): Promise<ApiResult<Article>> {
// Simulate different outcomes based on id
if (id <= 0) {
return { success: false, error: "Invalid article ID", status: 400 };
}
if (id > 100) {
return { success: false, error: "Article not found", status: 404 };
}
return {
success: true,
data: {
id,
title: `Article ${id}: TypeScript Deep Dive`,
body: "TypeScript brings static types to JavaScript...",
},
};
}
async function main() {
const results = await Promise.all([
fetchArticle(1),
fetchArticle(-5),
fetchArticle(999),
]);
for (const result of results) {
if (result.success) {
// TypeScript knows result.data is Article here
console.log(`Found: "${result.data.title}"`);
} else {
// TypeScript knows result.error and result.status here
console.log(`Error ${result.status}: ${result.error}`);
}
}
}
main();
The discriminant property success lets TypeScript narrow the type in each branch. In the if (result.success) branch, result.data is guaranteed to exist. In the else branch, result.error and result.status are available. You cannot accidentally access result.data in the error branch — the compiler prevents it.
Predict
render narrows on the success discriminant, but the error branch reaches for result.data.length anyway. This exact code is checked with tsc --strict AND run by the tsx runner on a { success: false } value. Predict BOTH: does tsc report an error, and what does the runner print or do?
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: string };
function render(result: ApiResult<string[]>): string {
if (result.success) {
return `ok: ${result.data.length} items`;
}
// error branch — reach for .data anyway
return `items: ${result.data.length}`;
}
console.log(render({ success: false, error: "boom" }));Runtime Type Guards
Interfaces and generics only exist at compile time. If your API returns something unexpected — a missing field, a wrong type, a null where you expected a string — TypeScript cannot catch that at runtime. Type guards bridge the gap.
A type guard is a function that returns value is T, which tells TypeScript: "if this function returns true, narrow the type to T in subsequent code."
interface Order {
id: number;
customerId: number;
items: string[];
total: number;
status: "pending" | "shipped" | "delivered";
}
function isOrder(value: unknown): value is Order {
if (typeof value !== "object" || value === null) return false;
const obj = value as Record<string, unknown>;
return (
typeof obj.id === "number" &&
typeof obj.customerId === "number" &&
Array.isArray(obj.items) &&
obj.items.every((item) => typeof item === "string") &&
typeof obj.total === "number" &&
(obj.status === "pending" ||
obj.status === "shipped" ||
obj.status === "delivered")
);
}
// Simulate three different payloads arriving from an API
const payloads: unknown[] = [
{ id: 1, customerId: 42, items: ["Widget", "Gadget"], total: 59.99, status: "shipped" },
{ id: 2, customerId: 7, items: ["Thing"], total: 12.0 }, // missing status
{ id: "bad", customerId: null, items: [], total: 0, status: "pending" }, // wrong types
];
for (const payload of payloads) {
if (isOrder(payload)) {
console.log(`Order #${payload.id} — ${payload.status} — $${payload.total}`);
} else {
console.log("Rejected: payload does not match Order shape");
}
}
Type guards work best when kept close to the API boundary. Validate once as data enters the system, then let the rest of your code work with fully typed values without any defensive checks.
Recall
Without scrolling up: isOrder above is written as function isOrder(value: unknown): value is Order. You met this value is T return type in 17-type-narrowing as a user-defined type guard. When you use such a guard in array.filter(isOrder), what does TypeScript infer the result type to be, and why does that matter at the API boundary?
Try It Yourself
Reading about typed API clients is not the same as building one. This is a build task: a small program that reports its own pass/fail. You will build the client that validates an untrusted (simulated) payload with a type guard, wraps the result in a discriminated union, and queries the typed survivors — no real network involved. Three functions are stubbed. Run it as-is and it fails immediately, naming the first stub. Implement each until every check passes and it prints All checks passed.
Build
Finish the build. Three functions are stubbed and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement the three functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
import assert from "node:assert";
// A type-safe API layer over a SIMULATED payload — no real network (the sandbox has
// none). rawPayload is the untyped `unknown` a response.json() would hand back, and
// one record is malformed. Do NOT change these.
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: string };
interface User {
id: number;
name: string;
role: "admin" | "member" | "guest";
}
const rawPayload: unknown[] = [
{ id: 1, name: "Alice", role: "admin" },
{ id: 2, name: "Bob", role: "member" },
{ id: 3, name: "Carol", role: "guest" },
{ id: 4, name: "Bad", role: "wizard" },
];
// TODO 1: a runtime type guard. Return `value is User` — true ONLY when value is an
// object with a numeric id, a string name, and a role that is one of the three
// literals ("admin" | "member" | "guest"). Reject anything else.
// isUser({ id: 1, name: "A", role: "admin" }) -> true
// isUser({ id: 1, name: "A", role: "wizard" }) -> false
// isUser({ id: "1", name: "A", role: "admin" }) -> false
function isUser(value: unknown): value is User {
// your code here
return false;
}
// TODO 2: validate the raw payload at the boundary. Keep only values that pass isUser
// (filter narrows the array to User[]). If NONE survive, return
// { success: false, error: "no valid users" }; otherwise { success: true, data }.
// loadUsers(rawPayload).success -> true, with 3 valid users
function loadUsers(raw: unknown[]): ApiResult<User[]> {
// your code here
return { success: false, error: "not implemented" };
}
// TODO 3: count how many typed users have exactly the given role.
// countByRole(users, "member") -> 1
function countByRole(users: User[], role: User["role"]): number {
// your code here
return 0;
}
// --- Build checks: these must all pass. Do not edit below this line. ---
assert.strictEqual(isUser({ id: 1, name: "A", role: "admin" }), true, "TODO 1: a well-formed user should pass the guard");
assert.strictEqual(isUser({ id: 1, name: "A", role: "wizard" }), false, "TODO 1: an unknown role should fail the guard");
assert.strictEqual(isUser({ id: "1", name: "A", role: "admin" }), false, "TODO 1: a non-numeric id should fail the guard");
assert.strictEqual(isUser({ id: 1, name: 42, role: "admin" }), false, "TODO 1: a non-string name should fail the guard");
const loaded = loadUsers(rawPayload);
assert.strictEqual(loaded.success, true, "TODO 2: the payload has valid users, so loadUsers should succeed");
assert.ok(loaded.success && loaded.data.length === 3, "TODO 2: exactly three of the four records are valid users");
assert.strictEqual(loadUsers([{ role: "ghost" }]).success, false, "TODO 2: an all-invalid payload should fail");
const users: User[] = loaded.success ? loaded.data : [];
assert.strictEqual(countByRole(users, "admin"), 1, "TODO 3: countByRole should count admins");
assert.strictEqual(countByRole(users, "member"), 1, "TODO 3: countByRole should count members");
console.log("All checks passed.");
console.log("Valid users:", users.length);
console.log("Admins:", countByRole(users, "admin"));
console.log("Guests:", countByRole(users, "guest"));Expected output: All checks passed.
Valid users: 3
Admins: 1
Guests: 1
Once it passes, try two variations and predict each before running:
- Weaken the guard. In
isUser, delete the role-literal check so the last line is justtypeof o.id === "number" && typeof o.name === "string". Predict which check fails first before running. The guard now accepts any role, soisUser({ id: 1, name: "A", role: "wizard" })returnstrue— and TODO 1's second check fires first withAssertionError: TODO 1: an unknown role should fail the guard. A guard that skips a field lets malformed data through the boundary; here the malformedwizardrecord would slip into your typedUser[]. - Invert the filter. In
loadUsers, changeraw.filter(isUser)toraw.filter((v) => !isUser(v))so it keeps the values that FAIL the guard. Predict which check fails first before running — and whattsc --strictsays separately. At run time the only surviving value is the one malformed record, soloaded.data.lengthis1, not3, and TODO 2'sdata.length === 3check fails withAssertionError: TODO 2: exactly three of the four records are valid users. Andtsc --strictcatches it a different way: a negated guard does NOT narrow, soraw.filter((v) => !isUser(v))has typeunknown[], which is not assignable toUser[]—error TS2322: Type 'unknown[]' is not assignable to type 'User[]'.The guard only narrows when you keep the values it accepts.
Arrange the code
Reassemble a program that calls a simulateFetch helper (a const arrow standing in for a real fetch — no network here), reads its result, derives an outcome from the success flag, formats a report line, and logs it. The lines are shuffled. Each const consumes the binding above it, so only one order runs top-to-bottom and logs result: Article 7.
console.log(report);const response = simulateFetch(7);const outcome = response.success ? response.data : "not found";const report = `result: ${outcome}`;const simulateFetch = (id: number): { success: boolean; data: string } => ({ success: id > 0, data: `Article ${id}` });
Key Takeaways
- Validate
unknownat the boundary, don't just cast it — acceptunknownfrom the network and check it once with a type guard;asasserts a shape without verifying it, so a mismatched payload compiles clean and fails at run time - Generic wrappers eliminate repetition — a single
apiGet<T>function gives every call site a typed return without duplicating boundary logic - Discriminated unions make errors explicit —
ApiResult<T>forces callers to handle failure; forgetting to checkresult.successis a compile-time error - Type guards validate at runtime — compile-time types disappear at runtime, so validate incoming data with
value is Tguard functions before trusting it - Narrow once, use everywhere — validate and narrow at the API layer; pass fully typed values to the rest of your application so business logic stays clean
Pro Tip: Libraries like Zod let you define a schema that simultaneously serves as a TypeScript type and a runtime validator — so you write the shape once and get both
isUser-style validation and full type inference for free. It's worth adopting on any project where API contracts matter.
Next Steps
You can now type a boundary end to end — validate an untrusted response, model success and failure as a union, and narrow with a guard. When one of those boundaries misbehaves, the next skill is reading what the compiler is telling you. Next, you'll learn to debug TypeScript: decoding error messages, using the type system to find logic bugs, exhaustiveness checks with never, and runtime techniques that keep type safety intact.
Ready to continue? Head to Debugging TypeScript!
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.