Typing API Responses
By the end of this lesson
Model server data and validate it at the boundary.
A type is a claim you make about your own code, checked before the code runs. Server data arrives while the code is running, long after any checking happened.
So writing interface Expense does not make an API response an Expense. It states what you are expecting. Whether the response matches is a separate question, and one that nothing in TypeScript answers on its own.
The server can legitimately send something else. A field is renamed in a deployment. An optional property turns out to be null rather than absent. A proxy returns an HTML error page. A different version of the API is live than the one you tested against. None of these are exotic; they are ordinary Tuesday events on a system with more than one team working on it.
One way to hold the whole topic: your application has an inside and an outside. Inside, types are reliable, because the checker verified every line against them. Outside, nothing is known. Validation is the door between the two, and where you put that door decides whether the types inside are guarantees or assumptions.
interface Expense {
id: string;
description: string;
amount: number;
status: "draft" | "submitted" | "approved" | "rejected";
}
async function loadExpenses(employeeId: string): Promise<Expense[]> {
const response = await fetch("/api/employees/" + employeeId + "/expenses");
if (!response.ok) {
throw new Error("Expenses request failed with status " + response.status);
}
// The only line that is not carrying its weight
return (await response.json()) as Expense[];
}
// Somewhere else entirely, much later
const total = expenses.reduce((sum, expense) => sum + expense.amount, 0);- The status check is right, and this function is better than most. The assertion is the weak point.
- response.json() returns Promise<any> — the values are genuinely unknown at that moment, and that is accurate.
- The assertion replaces that honest uncertainty with a specific claim. Nothing is checked. If the server renames amount to amountInPence, this line still succeeds.
- The failure surfaces in the reduce, where every amount is undefined and the total is NaN. The reduce is correct code. It will be read carefully, several times, by someone who has no reason to suspect the fetch.
- A NaN total is the good outcome. A missing status is worse, because the row renders with an empty badge and looks plausible, so the wrong data is trusted.
const STATUSES = ["draft", "submitted", "approved", "rejected"] as const;
function isExpense(value: unknown): value is Expense {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === "string" &&
typeof candidate.description === "string" &&
typeof candidate.amount === "number" &&
Number.isFinite(candidate.amount) &&
typeof candidate.status === "string" &&
(STATUSES as readonly string[]).includes(candidate.status)
);
}
async function loadExpenses(employeeId: string): Promise<Expense[]> {
const response = await fetch("/api/employees/" + employeeId + "/expenses");
if (!response.ok) {
throw new Error("Expenses request failed with status " + response.status);
}
const body: unknown = await response.json();
if (!Array.isArray(body) || !body.every(isExpense)) {
throw new Error("Expenses response did not match the expected shape");
}
return body;
}- value is Expense in the return position makes this a type predicate. When it returns true, the checker treats the value as an Expense from then on — the narrowing is earned by the checks rather than asserted.
- The parameter is unknown, so nothing can be read from it until its shape has been established. That is what forces the object check first.
- The one assertion here is to Record<string, unknown>, after confirming the value is a non-null object. It claims only that properties may be read and each is still unknown, so every field is checked individually. This is the narrow case where an assertion is reasonable.
- Number.isFinite rejects NaN and Infinity, which typeof number does not. An amount of NaN passes a typeof check and then poisons every total it touches.
- The status is checked against the actual allowed values, so a new status the frontend does not understand is caught here rather than rendering as a blank badge.
- The function now fails at the boundary, with a message naming the boundary. That is the whole win: the error appears where the bad data entered, not three components downstream.
A boundary is anywhere data enters your code from somewhere you do not control. All of these deserve the same treatment, and all of them are routinely asserted instead:
- API responses, including ones from your own team's services
- Values read from localStorage or sessionStorage — written by an older version of your own code, and editable by the reader
- URL parameters and query strings, which anyone can type
- Form input, which is always text and always arbitrary
- Messages from another window, a worker, or a server-sent stream
- Configuration loaded at run time, and data from third-party scripts
Summary
- A type is a compile-time claim; the server can send whatever it sends
- as relabels a value without checking it, and the failure then appears far from the cause
- Type unknown at the boundary and narrow it with a type predicate that does real checks
- Boundaries include stored values, URL parameters and messages, not only API responses
- Validate once at the edge so types inside the application are reliable rather than assumed
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Validate something you wrote yourself
Directory filters are saved to localStorage so they survive a reload: a department name, a status, and a sort order. Write the type and a validator, and load it safely.
Assume the stored value was written by a version of your code from six months ago, and that the reader can edit it in their browser's developer tools.
Show solution
localStorage is a boundary even though your own code wrote the value. The version that wrote it may not be the version reading it, and the reader can change it by hand. Both are ordinary, and both produce data your current types do not describe.
Returning defaults rather than throwing is the right call here, because a usable filter always exists. A corrupt saved filter should reset quietly, not stop the page loading. That is a deliberate difference from the API case, where failing loudly was correct.
JSON.parse is inside the try because it throws on invalid text, and invalid text is exactly what a hand-edited value can be. Forgetting this is a common way for a stored value to take down a page on load.
Checking each field separately means a partially valid saved filter can contribute what it has. Whether that is better than resetting everything is a product decision — the validator is where you get to make it explicitly.
type SortOrder = "name" | "department" | "startDate";
interface DirectoryFilter {
department: string | null;
activeOnly: boolean;
sortBy: SortOrder;
}
const DEFAULT_FILTER: DirectoryFilter = {
department: null,
activeOnly: true,
sortBy: "name",
};
const SORT_ORDERS = ["name", "department", "startDate"] as const;
function isSortOrder(value: unknown): value is SortOrder {
return typeof value === "string" && (SORT_ORDERS as readonly string[]).includes(value);
}
export function loadDirectoryFilter(): DirectoryFilter {
const stored = localStorage.getItem("directory-filter");
if (stored === null) {
return DEFAULT_FILTER;
}
try {
const parsed: unknown = JSON.parse(stored);
if (typeof parsed !== "object" || parsed === null) {
return DEFAULT_FILTER;
}
const candidate = parsed as Record<string, unknown>;
return {
department:
typeof candidate.department === "string" ? candidate.department : null,
activeOnly:
typeof candidate.activeOnly === "boolean" ? candidate.activeOnly : true,
sortBy: isSortOrder(candidate.sortBy) ? candidate.sortBy : "name",
};
} catch {
// Not valid JSON — a hand-edited or truncated value
return DEFAULT_FILTER;
}
}Think about it
Think about it
An expense API renames amount to amountInPence. The frontend uses as Expense[] on the response. Trace what a user sees, what the developer investigating it sees first, and how much of that would change with validation at the boundary.
Show solution
The user sees totals of NaN, or blank amounts, depending on how the value is formatted. The rows still appear, so it looks like a display bug rather than a data problem — which is how it will be reported.
The developer starts at the total, because that is where the wrong value is. The reduce is correct. Then the formatter, also correct. Then the component, also correct. The fetch is the last place they look, because it succeeded and returned an array of the expected length.
With validation, the request throws immediately with a message naming the shape mismatch, the panel shows its error state, and the log entry points at the endpoint. The investigation starts at the cause.
Worth being precise about what validation does not do: it does not keep the feature working. The data really is unusable. What it changes is how long it takes to find out why, and whether anyone was shown a wrong number in the meantime — and for money, being visibly broken is better than being quietly wrong.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.
End of the published lessons
That is everything written so far in Frontend Foundations
More lessons in this course are on the way. In the meantime, the course page shows the full roadmap, and the projects are the best way to consolidate what you have covered.