Normalize an API payload into a typed list
Canonical URL: https://devexamples.com/typescript/normalize-an-api-payload-into-a-typed-list/
The problem
The same endpoint has answered with a bare array, with an object holding an items array, and with an object holding a results array. Individual rows are missing the id, arrive with a null label, repeat an id already present, or carry a score that is not a number, and the screen below this call needs one stable array it can map over.
Short answer
Read the recognised envelope keys to reach the row array, validate each row against the declared item type, and return the accepted items together with one problem string per rejected or duplicate entry.
Endpoints drift. Before any reshaping or rendering, one function should take whatever arrived and promise a single array type, while keeping a written record of every entry it could not accept.
export type ReportRow = {
readonly id: string;
readonly label: string;
readonly score: number;
};
export type NormalisedList<TItem> = {
readonly items: readonly TItem[];
readonly problems: readonly string[];
};
const ENVELOPE_KEYS = ["items", "results", "data"] as const;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function extractRows(payload: unknown): readonly unknown[] | null {
if (Array.isArray(payload)) {
return payload;
}
if (!isRecord(payload)) {
return null;
}
for (const key of ENVELOPE_KEYS) {
const nested = payload[key];
if (Array.isArray(nested)) {
return nested;
}
}
return null;
}
function toReportRow(row: unknown, index: number, problems: string[]): ReportRow | null {
if (!isRecord(row)) {
problems.push(`entry ${index}: dropped, the value is not an object`);
return null;
}
const id = row["id"];
const score = row["score"];
const label = row["label"];
if (typeof id !== "string" || id === "") {
problems.push(`entry ${index}: dropped, no usable id`);
return null;
}
if (typeof score !== "number" || !Number.isFinite(score)) {
problems.push(`entry ${index} (${id}): dropped, score is not a finite number`);
return null;
}
return {
id,
score,
label: typeof label === "string" && label !== "" ? label : id,
};
}
export function normaliseReports(payload: unknown): NormalisedList<ReportRow> {
const problems: string[] = [];
const rows = extractRows(payload);
if (rows === null) {
problems.push("no array at the payload root or under items, results, or data");
return { items: [], problems };
}
const items: ReportRow[] = [];
const acceptedIds = new Set<string>();
rows.forEach((row, index) => {
const candidate = toReportRow(row, index, problems);
if (candidate === null) {
return;
}
if (acceptedIds.has(candidate.id)) {
problems.push(`entry ${index}: duplicate id ${candidate.id} was dropped`);
return;
}
acceptedIds.add(candidate.id);
items.push(candidate);
});
return { items, problems };
}The payload arrives over the network, so the await boundary stays next to the normaliser rather than inside it.
export async function fetchReports(url: string): Promise<NormalisedList<ReportRow>> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Reports request failed with status ${response.status}`);
}
const payload: unknown = await response.json();
return normaliseReports(payload);
}One call with this body exercises every branch of the normaliser:
{
"results": [
{ "id": "eu-west", "score": 12.5 },
{ "score": 4 },
"unexpected string",
{ "id": "eu-west", "score": 3 },
{ "id": "us-east", "score": null, "label": "US East" }
]
}Explanation
Normalisation is two decisions, and the code separates them. extractRows answers “where is the list?” by testing the root and then each key in ENVELOPE_KEYS, returning the first array it finds or null when the payload carries none. toReportRow answers “is this entry usable?” by checking each field against the declared ReportRow shape and returning either a complete row or nothing. Because both answers are explicit, the caller never has to guess whether an empty result means an empty endpoint or a payload it failed to read: the second case always writes a line into problems.
Everything entering the function is unknown, which is what makes the output type meaningful. Array.isArray on an unknown narrows it to an array of unchecked values, so the rows handed to toReportRow are still unknown and each field access goes through isRecord first — the index signature on Record<string, unknown> permits row["id"] while keeping the result untrusted until a typeof test resolves it. The annotations are what stop any from leaking: no value is ever asserted to be a ReportRow, and the only place a ReportRow is created is the return statement where all three fields have already been checked or defaulted. The label fallback to id is a deliberate product choice rather than a type decision, and it is the one field worth revisiting when you adapt the function, since a silent fallback is invisible in the problems list.
The duplicate filter uses a Set of accepted ids, so the first occurrence wins and the order of items matches payload order. That is a boundary condition you should confirm against your own data: if the endpoint sends an updated row after a stale one with the same id, keeping the first drops the update, and you would rather keep the last or key on a version field. The other boundary is the empty case — a payload of { "results": [] } returns an empty list with no problems, while { "results": null } returns an empty list with a problem, because null is not an array under any envelope key. A JSON null body and an empty collection are different facts and this function keeps them apart.
Expected output
items holds one ReportRow per accepted record in payload order, and problems holds one line per rejected, unusable, or duplicate entry, naming its index.
Usage notes
- Keep the envelope key list explicit and ordered; the first key holding an array wins, so put the shape your endpoint returns most often first.
- Surface problems in a debug panel or a log rather than in reader-facing copy, because the wording here names entry indexes.
Common mistakes
- Reaching for Array.isArray on the payload alone, which accepts an empty array as success and never reports the missing envelope key.
Caveats
- Normalising by dropping entries hides data loss unless the caller also reads the problems list, so treat a non-empty problems array as a signal to fix the endpoint.
- The field checks accept the documented JSON types only; a number sent as a numeric string is reported as a problem instead of being coerced.
Alternatives
Mapping straight onto a view model is shorter and fine once records are validated upstream; normalising first is the safer choice while the endpoint still changes shape, because rejected entries are named instead of crashing the render.
Prerequisite
Prerequisite
Group array items by a computed keyGrouping rows by a key first makes the shape this function returns concrete rather than theoretical.
Examples that treat this as a prerequisite
Derived at build time from the editorial graph; no edge is invented here.
- Narrow an unknown API response with type guards
The narrowed array this produces is the input that normaliser expects, so read the two together.
Related examples
Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.
Alternative approach
Transform server records into the view model a component rendersIf the records are already validated, map them straight to the view model instead of normalising again.
Same task or topic
Narrow an unknown API response with type guardsSolves the Transform data task
Same task or topic
Group array items by a computed keySolves the Transform data task
References
- Array.isArray()(opens in a new tab) — MDN (Mozilla). Documents the array test used to recognise a row list at the payload root or under an envelope key.
- Number.isFinite()(opens in a new tab) — MDN (Mozilla). Explains why NaN and Infinity fail this check, which is the numeric validation used per row.
- Object Types(opens in a new tab) — Microsoft (TypeScript handbook). Covers the index signature used to read arbitrary keys from a payload before any field is trusted.
Source page: https://devexamples.com/typescript/normalize-an-api-payload-into-a-typed-list/