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.
What You'll Learn
By the end of this lesson you will build a typed API client end to end: a response shape, a generic wrapper that carries that shape through, a result type that makes failure impossible to ignore, and a runtime guard that decides whether the data deserved its type in the first place. The through-line is one argument, and each step exists because the step before it was not enough — the cast is not a check, the wrapper does not validate, and only the guard looks at the value.
This lesson is not a milestone contributor; it is here for the moment a real project puts a network between you and your data. You arrive able to narrow a discriminated union (Type Narrowing) and to write a parser that returns its failure as a value (Error Handling, Zod And Validation); what is new is applying both at the one boundary where the data was produced by someone else's program.
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 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 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 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.
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. Put them back in an order that runs — then answer what the puzzle is really testing: which line could not have moved, and what does the runner do if it does?
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}` });
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" }));When the Discriminant Is Not Checked
The Predict above showed the compiler refusing the un-narrowed access. Here is the same mistake in a program that reaches the runner anyway, because the access is buried one line deeper than the eye tends to look:
Debug
This asks the simulated API for two articles and builds one summary line for each. The client is correct: it returns a proper failure result for the id that does not exist. The program still dies before it prints anything. Commit a hypothesis about what the map callback is holding on the second id, then fix it so both ids produce a line.
import assert from "node:assert";
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: string; status: number };
interface Article {
id: number;
title: string;
}
// Stands in for a real request: the sandbox is offline, so no fetch() here.
function simulateFetch(id: number): ApiResult<Article> {
if (id > 100) {
return { success: false, error: "Article not found", status: 404 };
}
return { success: true, data: { id, title: `Article ${id}` } };
}
// Build one summary line per requested id.
function summarise(ids: number[]): string[] {
return ids.map((id) => {
const result = simulateFetch(id);
const article = result.data;
return `#${id}: ${article.title}`;
});
}
const lines = summarise([1, 999]);
assert.ok(
!lines.some((line) => line.includes("undefined")),
"every summary should name a real article or a real error, got: " + lines.join(" | "),
);
console.log(lines.join("\n"));Expected output:
#1: Article 1 #999: error 404 Article not found
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 alone is not enough: it says the value is an array and
// nothing about what is in it. Given items: [1, 2, 3] the isArray check
// passes and the .every check fails — that second line is the one doing
// the work the type Order["items"] actually claims.
Array.isArray(obj.items) &&
obj.items.every((item) => typeof item === "string") &&
typeof obj.total === "number" &&
// Note this is a membership test, not typeof. status is a literal union,
// so typeof obj.status === "string" would accept "shipping" — a plausible
// typo that is a string but not one of the three permitted values.
(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.
Two of those checks deserve a second look, because each replaces an easier check that would have looked right. Both were measured against the payload { items: [1, 2, 3], status: "shipping" }: Array.isArray(obj.items) alone reports true for an array of numbers, while adding .every((item) => typeof item === "string") reports false — the second line is what makes the guard mean string[] rather than merely "some array". Likewise typeof obj.status === "string" reports true for "shipping", while the three-way membership test reports false. A guard is only as strong as its narrowest check, and both of the weaker forms produce a value the compiler will then treat as a fully valid Order.
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 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.
Transfer
This lesson's client draws one boundary: JSON arrives untyped, a guard checks it, and everything past the guard is a real Order. The same program written in plain JavaScript has no compiler to enforce anything past that point, and a Rust client using serde would refuse to build a struct the bytes do not support. Which statement names what genuinely transfers between the three?
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.