Week 13: Generics, Utility Types & Narrowing

The last TypeScript-fundamentals week, and the one that unlocks writing genuinely reusable typed code instead of retyping the same shape repeatedly. Generics let a function or interface work with a type decided at the call site; utility types transform an existing type instead of writing a new one from scratch; narrowing is how unknown from Week 11 actually becomes usable.

Phase 7 of 8 Week 13 of 14 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write a generic function or interface that works with a type supplied at the call site
  • Use Partial, Pick, Omit and Record to derive a type from an existing one
  • Narrow a broad type down to a specific one with a type guard before using it

1. Generic Functions

A generic is a type parameter — a placeholder type, filled in when the function is actually called, that lets one function definition work correctly across many different types without losing type safety:

without generics: either unsafe, or duplicated
// Unsafe: works for anything, but tells you nothing about what comes back out
function firstElementAny(arr: any[]): any {
  return arr[0];
}

const num = firstElementAny([1, 2, 3]);       // typed as `any` -- no safety at all
with a generic: one function, fully typed either way
function firstElement<T>(arr: T[]): T {
  return arr[0];
}

const num = firstElement([1, 2, 3]);         // T is inferred as number -- num: number
const str = firstElement(["a", "b", "c"]);   // T is inferred as string -- str: string

num.toFixed(2);   // fine -- TypeScript knows num is a number
// str.toFixed(2); // ERROR -- TypeScript knows str is a string, not a number

<T> declares a type parameter named T (a conventional name — it could be anything) that stands in for "whatever type is actually passed in." TypeScript infers T automatically from the argument at each call site, so firstElement([1,2,3]) and firstElement(["a","b"]) both stay fully and correctly typed, using the exact same function definition — no duplication, and no falling back to any.

2. Generic Interfaces

Interfaces can take type parameters too — useful for a shape that wraps some other type, like an API response envelope:

a generic API-response shape
interface ApiResponse<T> {
  success: boolean;
  data: T;
  error?: string;
}

interface User {
  id: number;
  name: string;
}

const userResponse: ApiResponse<User> = {
  success: true,
  data: { id: 1, name: "Priya" }, // data is typed as User specifically here
};

const numbersResponse: ApiResponse<number[]> = {
  success: true,
  data: [1, 2, 3], // data is typed as number[] here instead
};

ApiResponse<T> is written once, but ApiResponse<User> and ApiResponse<number[]> are two distinct, fully-typed shapes derived from it — data's type changes to match whatever T was filled in with at each usage. This is exactly how you'd type a real API layer: one reusable envelope shape, wrapping whatever specific payload each endpoint actually returns.

3. Utility Types

TypeScript ships several built-in generic types that transform an existing type instead of requiring a new one written from scratch:

Partial, Pick, Omit & Record
interface User {
  id: number;
  name: string;
  email: string;
}

// Partial<T>: every property becomes optional -- perfect for an "update" function
type UserUpdate = Partial<User>;
// equivalent to: { id?: number; name?: string; email?: string }

function updateUser(id: number, changes: UserUpdate) { /* ... */ }
updateUser(1, { name: "New Name" }); // fine -- only name is being changed

// Pick<T, Keys>: keep ONLY the listed properties
type UserPreview = Pick<User, "id" | "name">;
// equivalent to: { id: number; name: string }

// Omit<T, Keys>: keep everything EXCEPT the listed properties
type UserWithoutEmail = Omit<User, "email">;
// equivalent to: { id: number; name: string }

// Record<Keys, ValueType>: an object type with specific keys, all of one value type
type UserRolePermissions = Record<"admin" | "editor" | "viewer", boolean>;
// equivalent to: { admin: boolean; editor: boolean; viewer: boolean }

All four are derived from User rather than redefined by hand — if a property is later added to User, Partial<User> and Omit<User, "email"> both pick that change up automatically, with nothing else needing to be updated. This is the real value of utility types: one source of truth for a shape, with variations derived from it instead of duplicated and allowed to drift out of sync.

4. Type Narrowing & Type Guards

Narrowing is how TypeScript moves from a broad type (like a union, or unknown from Week 11) down to a more specific one, based on a runtime check:

narrowing a union with typeof
function formatValue(value: string | number): string {
  if (typeof value === "string") {
    return value.toUpperCase(); // TypeScript knows: value is `string` in this branch
  }
  return value.toFixed(2);       // and knows: value is `number` in this branch
}
a custom type guard
interface Cat { meow(): void; }
interface Dog { bark(): void; }

// The `pet is Cat` return type is a TYPE GUARD -- it tells TypeScript what
// TRUE actually means for the type of `pet`, not just that it's a boolean.
function isCat(pet: Cat | Dog): pet is Cat {
  return "meow" in pet;
}

function makeSound(pet: Cat | Dog) {
  if (isCat(pet)) {
    pet.meow(); // narrowed to Cat here
  } else {
    pet.bark(); // narrowed to Dog here
  }
}

Inside the if (typeof value === "string") branch, TypeScript automatically treats value as a plain string — calling .toUpperCase() is safe there in a way it wouldn't be on the original union type. A custom type guard (pet is Cat) extends this same narrowing to your own logic, whenever the check is too specific for typeof or instanceof alone to express.

5. Enums

An enum is TypeScript's dedicated syntax for a fixed set of named constants — an alternative to Week 12's string-literal union for the same "one of these exact options" idea:

a string enum
enum Status {
  Pending = "PENDING",
  Active = "ACTIVE",
  Completed = "COMPLETED",
}

function setStatus(status: Status) {
  console.log(`Status: ${status}`);
}

setStatus(Status.Active); // "Status: ACTIVE" -- referenced via the enum, not a bare string

An enum groups the related constants under one namespace (Status.Active, not a bare, disconnected string) and gives autocomplete a concrete list to suggest. A union of string literals ("pending" | "active" | "completed") achieves a very similar practical result with no extra runtime object generated at all — enums are worth knowing and recognizing, but plenty of TypeScript codebases prefer literal unions specifically to avoid that small amount of extra generated code.

6. Hands-on Exercise

Hands-on

Build a typed generic API cache

Combine generics, utility types and narrowing into one small, realistic utility.

Requirements:

  1. Write a generic function getFirstMatch<T>(items: T[], predicate: (item: T) => boolean): T | undefined (reusing Week 5's find logic underneath).
  2. Define a Product interface with id, name, price, and category.
  3. Write a function updateProduct(product: Product, changes: Partial<Product>): Product that returns a new, merged object (spread, from Week 6) without mutating the original.
  4. Define type ProductPreview = Pick<Product, "id" | "name">, and write a function that converts an array of full Products into an array of ProductPreviews using map.
  5. Write a function accepting unknown that uses a custom type guard to check whether the value is a valid Product (has all four expected properties with the right types) before treating it as one.
Hint

Step 5's type guard can check each property with typeof: typeof value === "object" && value !== null && "id" in value && typeof (value as any).id === "number" && ... — verbose, but this is genuinely what validating unknown data (like a parsed JSON API response) looks like in practice.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why is function firstElement<T>(arr: T[]): T better than typing the same function's parameter and return value as any?

The generic version stays fully type-safe for every call: passing a number[] in gives back a properly-typed number, and passing a string[] in gives back a properly-typed string — TypeScript infers T from the actual argument each time. An any-typed version accepts and returns anything with zero checking at all, silently allowing mistakes an any return value would never catch.

Q2

Given interface User {'{'} id: number; name: string; email: string; {'}'}, what does Omit<User, "email"> produce?

A type equivalent to {'{'} id: number; name: string; {'}'} — every property from User except email. If User later gains a new property, Omit<User, "email"> automatically includes it too (still excluding only email), since it's derived from User rather than written out separately.

Q3

Inside if (typeof value === "string") {'{'} ... {'}'}, why can you safely call value.toUpperCase() even if value's declared type is string | number?

This is narrowing. TypeScript recognizes the typeof value === "string" check and, specifically inside that if block, treats value as the narrower type string rather than the full string | number union — so calling a string-only method there is safe and doesn't need an additional type assertion.

Q4

What does the special return type pet is Cat tell TypeScript, that a plain boolean return type wouldn't?

A plain boolean only tells the caller "true or false" — TypeScript wouldn't know why it was true, so it couldn't narrow anything from it. pet is Cat is a type predicate: it tells TypeScript specifically that when this function returns true, the argument's type can be narrowed to Cat from that point on — which is exactly what lets pet.meow() type-check safely inside the if branch.