TypeScript Generals: A Practical Walkthrough With Real Code

TypeScript Generals: A Practical Walkthrough With Real Code

If you’ve been writing TypeScript for a while, you’ve probably hit a wall where you want a function or class to work with multiple types without sacrificing type safety. That’s exactly where generics come in. This guide breaks them down from the ground up with practical, copy-paste-ready examples.


Prerequisites

Before diving in, you should have:

  • Node.js 20+ installed on your machine
  • TypeScript 5.4+ (we reference the latest compiler features)
  • A basic understanding of TypeScript fundamentals: interfaces, union types, and basic functions
  • Familiarity with ES6+ JavaScript features like arrow functions and destructuring

You can set up a sandbox project quickly:

mkdir generics-practice && cd generics-practice
npm init -y
npm install -D typescript@5.4.5 ts-node@10.9.2
npx tsc --init --strict

The --strict flag matters here because it enables noImplicitAny, which forces you to handle generics explicitly — perfect for learning.


Why Generics Exist

Let’s start with a problem. Suppose you want a function that returns whatever you pass into it:

function identity(value: any): any {
  return value;
}

const result = identity("hello");
// result is typed as `any` — you've lost all type information

This works, but it throws away the type information. The compiler can’t tell you that result.toUpperCase() is safe. Generics fix that by letting you define a type variable:

function identity<T>(value: T): T {
  return value;
}

const text = identity("hello");        // T is inferred as string
const count = identity(42);            // T is inferred as number

// Now the compiler knows:
console.log(text.toUpperCase());       // ✅ Valid
console.log(text.toFixed(2));          // ❌ Error: Property 'toFixed' does not exist on type 'string'

The <T> is a type parameter. Think of it like a placeholder that gets filled in when the function is called.


Generic Functions in Practice

Basic Syntax

Here’s the general pattern:

function firstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}

const numbers = firstElement([1, 2, 3]);        // number | undefined
const names = firstElement(["Ada", "Grace"]);    // string | undefined

Multiple Type Parameters

Functions can accept multiple generics:

function pair<K, V>(key: K, value: V): { key: K; value: V } {
  return { key, value };
}

const entry = pair("id", 1007);
// { key: string; value: number }

Generic Arrow Functions

When writing arrow functions, you need a small workaround in .tsx files (like React) because <T> looks like JSX. Use a trailing comma:

const wrap = <T,>(value: T): T[] => [value];

// In regular .ts files, this works fine too:
const wrapSafe = <T>(value: T): T[] => [value];

Generic Interfaces and Type Aliases

Generics aren’t limited to functions. They shine in data structures.

Generic Interfaces

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: Date;
}

type User = {
  id: number;
  email: string;
};

const response: ApiResponse<User> = {
  data: { id: 1, email: "ada@example.com" },
  status: 200,
  message: "OK",
  timestamp: new Date(),
};

Generic Type Aliases

type PaginatedResult<T> = {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
};

// Usage with a product catalog
type Product = { sku: string; price: number };

const products: PaginatedResult<Product> = {
  items: [
    { sku: "WIDGET-001", price: 19.99 },
    { sku: "WIDGET-002", price: 29.99 },
  ],
  total: 142,
  page: 1,
  pageSize: 20,
};

Generic Classes

Classes use generics to create reusable, type-safe structures. A classic example is a typed event emitter:

class DataStore<T> {
  private items: T[] = [];

  add(item: T): void {
    this.items.push(item);
  }

  getAll(): T[] {
    return [...this.items];
  }

  find(predicate: (item: T) => boolean): T | undefined {
    return this.items.find(predicate);
  }

  remove(predicate: (item: T) => boolean): void {
    this.items = this.items.filter((item) => !predicate(item));
  }
}

// Instantiate with a specific type
const userStore = new DataStore<{ id: number; name: string }>();
userStore.add({ id: 1, name: "Ada Lovelace" });
userStore.add({ id: 2, name: "Grace Hopper" });

const found = userStore.find((u) => u.name === "Ada Lovelace");
console.log(found); // { id: 1, name: 'Ada Lovelace' }

The type parameter T is available throughout the class — in properties, methods, and return types.


Constraints With extends

Left unchecked, generics accept anything. Sometimes you need to restrict what a type parameter can be. That’s where extends comes in.

Constraining to a Shape

interface HasId {
  id: number;
}

function getById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find((item) => item.id === id);
}

type Article = HasId & { title: string; body: string };

const articles: Article[] = [
  { id: 1, title: "Generics 101", body: "..." },
  { id: 2, title: "Advanced Types", body: "..." },
];

const article = getById(articles, 1);
//    ^? Article | undefined

The constraint ensures T always has an id property, so the function can safely access it.

The keyof Operator

A common pattern combines extends with keyof to create type-safe property accessors:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person = { name: "Ada", age: 36, role: "engineer" };

const name = getProperty(person, "name");    // string
const age = getProperty(person, "age");       // number

// TypeScript catches typos at compile time:
const invalid = getProperty(person, "email"); // ❌ Error: Argument of type '"email"' is not assignable to parameter of type '"name" | "age" | "role"'

Default Type Parameters

You can provide default types for generic parameters, similar to default arguments in functions:

interface requestOptions {
  retries: number;
}

function createFetcher<T = unknown, O extends requestOptions = requestOptions>(
  transform: (raw: unknown) => T
) {
  return async (url: string): Promise<T> => {
    const res = await fetch(url);
    const raw = await res.json();
    return transform(raw);
  };
}

// Explicit type parameter
const fetchUser = createFetcher<{ name: string }>((raw) => raw as { name: string });

// Default kicks in (T becomes unknown)
const fetchRaw = createFetcher((raw) => raw);

Defaults are especially useful in library code where most users want a sensible default but power users need customization.


Conditional Types

This is where generics start feeling like metaprogramming. A conditional type selects one of two types based on a condition:

type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">;  // true
type B = IsString<42>;       // false

A more practical example — unwrapping types:

type Unwrap<T> = T extends Promise<infer U> ? U : T;

type Resolved = Unwrap<Promise<number>>;   // number
type Plain   = Unwrap<string>;              // string

The infer keyword declares a new type variable within a conditional — it captures whatever type is in that position.

Practical Conditional Type: Deep Readonly

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

type Config = {
  api: {
    baseUrl: string;
    timeout: number;
  };
  features: string[];
};

type FrozenConfig = DeepReadonly<Config>;
// Everything is deeply readonly — useful for immutability

Built-in Utility Types That Use Generics

TypeScript ships with several utility types built on generics. Here are the ones you’ll use most:

// Partial — makes all properties optional
type PartialUser = Partial<User>;
// Equivalent to: { id?: number; email?: string }

// Pick — select specific properties
type UserEmail = Pick<User, "email">;
// Equivalent to: { email: string }

// Omit — remove specific properties
type UserWithoutId = Omit<User, "id">;
// Equivalent to: { email: string }

// Record — a typed map/dictionary
type UserMap = Record<number, User>;
// Keys are numbers, values are Users

// ReturnType — extract the return type of a function
function getConfig() {
  return { port: 3000, host: "localhost" };
}
type Config = ReturnType<typeof getConfig>;
// { port: number; host: string }

Understanding these deeply means you can compose them:

type UpdateUserInput = Partial<Pick<User, "email">>;
// { email?: string }

Common Pitfalls and How to Avoid Them

Pitfall 1: Overusing any Inside Generic Functions

The mistake:

function parse<T>(json: string): T {
  return JSON.parse(json); // Return type is `any`, cast to T silently
}

This looks type-safe but isn’t. JSON.parse returns any, and the function signature claims it returns T. The caller gets no real safety.

The fix: Add a runtime validation layer or use a library like zod:

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;

function parseUser(json: string): User {
  return UserSchema.parse(JSON.parse(json));
}

Pitfall 2: Generic Type Parameters You Don’t Use

The mistake:

function log<T>(message: string): void {
  console.log(message);
  // T is declared but never used
}

TypeScript 5.4 flags this in some configurations. If you don’t use the type parameter in the function body or signature, remove it.

Pitfall 3: Assuming Generics Validate at Runtime

Generics are compile-time only. They don’t exist after transpilation. This means:

function isString<T>(value: T): boolean {
  return typeof value === "string"; // This works, but not because of T
}

If you need runtime type checking, you have to implement it explicitly:

function assertString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new Error(`Expected string, got ${typeof value}`);
  }
}

Pitfall 4: Forgetting That Generic Inference Can Surprise You

function combine<T>(a: T[], b: T[]): T[] {
  return [...a, ...b];
}

const result = combine([1, 2, 3], ["four"]); 
// No error! T is inferred as `string | number`

The compiler widens T to accommodate both arrays. If you want strict matching, add an explicit type argument:

const strict = combine<number>([1, 2, 3], [4, 5]); // ✅
const error = combine<number>([1, 2, 3], ["four"]); // ❌ Type 'string' is not assignable to type 'number'

Real-World Use Cases

1. A Type-Safe Event Bus

type EventHandler<T = unknown> = (payload: T) => void;

class EventBus<EventMap extends Record<string, unknown>> {
  private handlers: { [K in keyof EventMap]?: EventHandler<EventMap[K]>[] } = {};

  on<K extends keyof EventMap>(event: K, handler: EventHandler<EventMap[K]>): void {
    (this.handlers[event] ??= []).push(handler);
  }

  emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {
    this.handlers[event]?.forEach((handler) => handler(payload));
  }
}

// Define your application's events
interface AppEvents {
  userLoggedIn: { userId: number; timestamp: Date };
  purchaseCompleted: { orderId: string; total: number };
  errorOccurred: { message: string; code: number };
}

const bus = new EventBus<AppEvents>();

bus.on("userLoggedIn", ({ userId, timestamp }) => {
  console.log(`User ${userId} logged in at ${timestamp.toISOString()}`);
});

bus.emit("userLoggedIn", { userId: 42, timestamp: new Date() });

// These are compile-time errors:
bus.emit("userLoggedIn", { userId: "42" }); // ❌ Type 'string' is not assignable to type 'number'
bus.on("unknownEvent", () => {});            // ❌ Argument of type '"unknownEvent"' is not assignable...

2. A Generic Repository Pattern

interface Repository<T extends { id: string }> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: string): Promise<void>;
}

class InMemoryRepository<T extends { id: string }> implements Repository<T> {
  private store = new Map<string, T>();

  async findById(id: string): Promise<T | null> {
    return this.store.get(id) ?? null;
  }

  async findAll(): Promise<T[]> {
    return Array.from(this.store.values());
  }

  async save(entity: T): Promise<T> {
    this.store.set(entity.id, entity);
    return entity;
  }

  async delete(id: string): Promise<void> {
    this.store.delete(id);
  }
}

// Usage
type Task = { id: string; title: string; done: boolean };

const taskRepo = new InMemoryRepository<Task>();
await taskRepo.save({ id: "task-1", title: "Write article", done: false });
const allTasks = await taskRepo.findAll();

3. Type-Safe API Client

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";

interface Endpoint<TParams extends unknown[], TResponse> {
  method: HttpMethod;
  path: (...params: TParams) => string;
  parse: (raw: unknown) => TResponse;
}

function request<TParams extends unknown[], TResponse>(
  endpoint: Endpoint<TParams, TResponse>,
  ...params: TParams
): Promise<TResponse> {
  return fetch(endpoint.path(...params), { method: endpoint.method })
    .then((res) => res.json())
    .then(endpoint.parse);
}

// Define endpoints
const getUser = {
  method: "GET" as HttpMethod,
  path: (id: number) => `/api/users/${id}`,
  parse: (raw: unknown) => raw as { id: number; name: string },
};

// Type-safe calls
const user = await request(getUser, 42);
//    ^? { id: number; name: string }

// Compile-time error: wrong parameter type
const bad = await request(getUser, "42"); // ❌ Argument of type 'string' is not assignable to parameter of type 'number'

Advanced Pattern: Mapped Types

Generics combine with mapped types to transform object shapes programmatically:

type Stringify<T> = {
  [K in keyof T]: string;
};

type Point = { x: number; y: number };
type StringPoint = Stringify<Point>;
// { x: string; y: string }

// Make all methods optional
type OptionalMethods<T> = {
  [K in keyof T]?: T[K];
};

// Add a prefix to all keys
type Prefix<T, P extends string> = {
  [K in keyof T as `${P}${Capitalize<string & K>}`]: T[K];
};

type PrefixedUser = Prefix<{ name: string; email: string }, "user">;
// { userName: string; userEmail: string }

These patterns are the backbone of many popular libraries — zod, typebox, and ORM query builders all rely on them.


Performance and Compilation Considerations

Deeply nested generic types can slow down the TypeScript compiler. If you notice your build times creeping up:

  1. Avoid recursive types beyond a reasonable depthDeepReadonly over a 10-level nested object can be expensive.
  2. Use simpler type aliases for internal intermediate types.
  3. Profile with tsc --extendedDiagnostics to identify bottlenecks:
npx tsc --noEmit --extendedDiagnostics

The output shows time spent in type checking and the number of types instantiated.


Key Takeaways

  • Generics are compile-time only. They vanish after transpilation — design for compile-time safety, not runtime behavior.
  • Start simple. A basic <T> parameter covers most use cases. Reach for constraints and conditional types

Leave a Reply

Your email address will not be published. Required fields are marked *