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.

Typescript
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:

Typescript
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:

Typescript
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:

Typescript
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:

Typescript
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:

Typescript
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:

Typescript
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 for undefined before 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.