Generics & Utility Types
Advanced · 17 min read · ▶ live playground · ✦ checkpoint
Once you can describe one value, the next step is describing patterns that work across many values. Generics let a function or type keep track of the specific type it was given, and utility types let you reshape existing types without copying the same object fields by hand.
This is still a static TypeScript taster inside the JavaScript course. The full live compiler workflow belongs in the TypeScript course; here, focus on the mental model.
Generic functions
A generic is a type with a blank to fill in later. The conventional blank name is T, short for "type", but you can use clearer names when it helps.
Without generics, a reusable function often throws away useful information. With generics, the function says: "give me an array of something, and I will return that same something."
type MiniCartItem = {
sku: string;
priceCents: number;
};
function firstItem<T>(items: T[]): T | undefined {
return items[0];
}
const firstName = firstItem(["Mina", "Rae", "Ari"]);
const firstPrice = firstItem([1299, 2499, 399]);
const cartItems: MiniCartItem[] = [{ sku: "pen", priceCents: 149 }];
const firstCartItem = firstItem(cartItems);Read firstItem<T> as "firstItem has a type parameter named T." When you pass string[], T becomes string, so the result is string | undefined. When you pass number[], T becomes number. When you pass MiniCartItem[], T becomes MiniCartItem.
The | undefined matters because an array can be empty. Generics do not remove real runtime possibilities; they preserve the type relationship honestly.
You usually do not write the type argument yourself. TypeScript infers it from the value you pass:
function pairWithId<Value>(id: string, value: Value): { id: string; value: Value } {
return { id, value };
}
const receiptPair = pairWithId("receipt_1", { totalCents: 2499, paid: false });
const tagPair = pairWithId("tag_1", "urgent");Value is the blank. In the first call, it becomes { totalCents: number; paid: boolean }. In the second, it becomes string. The function body is shared, but each call keeps its own type.
Generic types
Generic types are the same idea applied to object shapes. They are common for wrappers: API results, pages of data, form fields, cache entries, and event payloads.
type ApiResult<Data> =
| { ok: true; data: Data }
| { ok: false; error: string };
type Page<Item> = {
items: Item[];
nextCursor?: string;
};
type ProductSummary = {
id: string;
title: string;
priceCents: number;
};
function productTitles(result: ApiResult<Page<ProductSummary>>): string[] {
if (!result.ok) {
return [`Error: ${result.error}`];
}
return result.data.items.map((product) => product.title);
}
const productPage: ApiResult<Page<ProductSummary>> = {
ok: true,
data: {
items: [
{ id: "notebook", title: "Notebook", priceCents: 1299 },
{ id: "mug", title: "Mug", priceCents: 1599 },
],
},
};
const titles = productTitles(productPage);ApiResult<Data> does not care whether the data is a product, user, receipt, or page. It describes the wrapper once. Page<Item> describes pagination once. Then ApiResult<Page<ProductSummary>> combines those promises: this result either failed with an error, or succeeded with a page of product summaries.
This is the practical value of generics: less duplicated typing, more exact relationships.
Utility types reshape existing types
TypeScript ships with standard utility types: generic types that transform other types. They are not runtime functions. They produce new type shapes from existing ones.
Start with one source shape:
type ProductDetails = {
id: string;
title: string;
description: string;
priceCents: number;
inStock: boolean;
tags: string[];
};
type ProductDraft = Partial<ProductDetails>;
type ProductCard = Pick<ProductDetails, "id" | "title" | "priceCents">;
type ProductCreateInput = Omit<ProductDetails, "id">;
type ProductInventory = Record<string, number>;
const draftProduct: ProductDraft = {
title: "Travel Mug",
};
const cardProduct: ProductCard = {
id: "mug",
title: "Travel Mug",
priceCents: 1899,
};
const createProduct: ProductCreateInput = {
title: "Travel Mug",
description: "A mug with a sealed lid.",
priceCents: 1899,
inStock: true,
tags: ["kitchen", "travel"],
};
const inventoryBySku: ProductInventory = {
mug: 12,
notebook: 5,
};Here is the daily-use translation:
Partial<ProductDetails>makes every property optional. Useful for drafts and patch updates.Pick<ProductDetails, "id" | "title" | "priceCents">keeps those selected properties.Omit<ProductDetails, "id">keeps everything except selected properties.Record<string, number>means an object whose keys are strings and whose values are numbers.
The win is drift prevention. If ProductDetails.priceCents later changes shape, every utility-derived type follows the source instead of relying on copied fields scattered across files.
ReturnType reads a function result
ReturnType is another utility. It extracts the return type from a function type.
type ReceiptLine = {
priceCents: number;
inStock: boolean;
};
function buildReceiptSummary(items: ReceiptLine[]) {
const totalCents = items.reduce((sum, item) => sum + item.priceCents, 0);
return {
itemCount: items.length,
totalCents,
hasBackorder: items.some((item) => !item.inStock),
};
}
type ReceiptSummary = ReturnType<typeof buildReceiptSummary>;
function receiptStatus(summary: ReceiptSummary): string {
return summary.hasBackorder ? "Some items need attention" : "Ready";
}typeof buildReceiptSummary asks TypeScript for the type of the function value. ReturnType<...> asks for that function's return type. The result is the object shape with itemCount, totalCents, and hasBackorder.
Do not reach for ReturnType everywhere. It is most useful when a function already owns the shape and naming the return type separately would create duplication.
unknown vs any
Two TypeScript types matter a lot at boundaries: unknown and any.
unknown means "I do not know what this is yet." TypeScript makes you check before using it.
function readMessage(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (
typeof value === "object" &&
value !== null &&
"message" in value &&
typeof value.message === "string"
) {
return value.message;
}
return "No message";
}
const messageFromText = readMessage(" Saved ");
const messageFromObject = readMessage({ message: "Uploaded" });any means "turn off checking here." That sounds convenient, but it behaves like a loan shark: it gives you speed now and collects interest later. Once any enters a value path, TypeScript stops protecting operations on that value, and bugs can move through the program disguised as safe code.
Use unknown for data you have not validated yet: API responses, user input, pasted JSON, localStorage, files, and other outside boundaries. Reach for any only as a temporary escape hatch, and treat it as debt that needs a clear reason and a plan to remove.
Generics let one type relationship stay precise across many values. Utility types let you derive new shapes from trusted source shapes. In the next lesson, you will see how teams adopt TypeScript gradually: configuration, @ts-check, JSDoc, file-by-file migration, and CI checks that keep typed code from drifting backward.
Checkpoint
Answer all three to mark this lesson complete