Advanced TypeScript Types
Generics, utility types like Partial and Pick, and type narrowing with typeof, in and discriminated unions.
Generics
A generic is a type parameter — it lets a function, interface, or class work with any type while still preserving the relationship between its inputs and outputs, instead of falling back to any and losing type safety entirely.
function firstElement<T>(items: T[]): T {
return items[0];
}
const num = firstElement([1, 2, 3]); // T inferred as number, num: number
const str = firstElement(["a", "b", "c"]); // T inferred as string, str: string
Without the generic <T>, you'd either write this function once per type, or type the parameter as any[] and lose the guarantee that the return value matches whatever went in.
Generics work on interfaces too:
interface ApiResponse<T> {
data: T;
success: boolean;
}
const userResponse: ApiResponse<{ name: string }> = {
data: { name: "Ada" },
success: true,
};
Constraints (extends) restrict a generic to types that have certain properties, so the function body can safely rely on them existing:
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(item: T): T {
console.log(item.length);
return item;
}
logLength("hello"); // fine — strings have .length
logLength([1, 2, 3]); // fine — arrays have .length
// logLength(42); // Error: number doesn't have a .length property
Utility types
TypeScript ships several built-in generic types that transform an existing type into a new one — extremely common in real codebases:
interface User {
id: number;
name: string;
email: string;
}
// Partial<T> — every property becomes optional (great for "update" functions)
function updateUser(id: number, changes: Partial<User>) {
// changes might only include { name: "New Name" }
}
// Pick<T, Keys> — a new type with only the listed properties
type UserPreview = Pick<User, "id" | "name">;
// { id: number; name: string }
// Omit<T, Keys> — a new type with the listed properties removed
type UserWithoutEmail = Omit<User, "email">;
// { id: number; name: string }
// Record<Keys, ValueType> — an object type mapping every key in Keys to ValueType
type UserRolesById = Record<number, "admin" | "member">;
// { [id: number]: "admin" | "member" }
// Readonly<T> — every property becomes readonly
const frozenUser: Readonly<User> = { id: 1, name: "Ada", email: "ada@example.com" };
// frozenUser.name = "Grace"; // Error: Cannot assign to 'name' because it is a read-only property
Type narrowing
TypeScript can automatically narrow a broader type down to a more specific one inside a conditional, based on a runtime check — this is how you safely work with union types.
typeof narrowing — for primitives:
function formatValue(value: string | number) {
if (typeof value === "string") {
return value.toUpperCase(); // TypeScript knows value is string here
}
return value.toFixed(2); // and knows it's number here
}
in narrowing — checking whether a property exists on an object:
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
side: number;
}
function area(shape: Circle | Square) {
if ("radius" in shape) {
return Math.PI * shape.radius ** 2; // narrowed to Circle
}
return shape.side ** 2; // narrowed to Square
}
Discriminated unions — the most robust pattern, using a shared literal property (like kind above) to distinguish members of a union, checked with switch:
type Shape = Circle | Square;
function describe(shape: Shape): string {
switch (shape.kind) {
case "circle":
return `Circle with radius ${shape.radius}`;
case "square":
return `Square with side ${shape.side}`;
}
}
If you add a third shape to the Shape union later and forget to handle it in the switch, adding a default: const _exhaustive: never = shape; line makes the compiler flag the missing case at compile time — a common defensive pattern called an exhaustiveness check.
Common mistakes
- Reaching for a generic constraint (
<T extends object>) so loosely that it provides no real safety — a constraint is only useful if the function body actually relies on the properties it guarantees. - Trying to narrow a union with a plain
if (shape.kind)truthiness check instead of comparing it to a specific literal (shape.kind === "circle") — narrowing needs a comparison the compiler can reason about. - Forgetting that
Partial<T>makes properties optional but does not make them nullable-safe automatically — you still need to check forundefinedbefore using an optional property.
Interview questions
Q: Why use generics instead of any?
any discards all type information, so the compiler can't verify anything about how a value is used or what a function returns. A generic like function first<T>(items: T[]): T preserves the actual relationship between input and output — passing in string[] guarantees you get a string back, fully type-checked, without writing a separate overload for every possible type.
Q: What's a discriminated union, and why is it useful?
It's a union of object types that all share one common literal property (often called kind or type) that identifies which member of the union a given value is. Switching on that property lets TypeScript automatically narrow the rest of the object's shape inside each branch, which is far safer and more maintainable than a set of loosely related optional fields with manual if checks scattered everywhere.