Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 040 / 100

Generics fundamentals: a practical guide for developers

Reusable code that keeps types precise.

Expona Admin9 min read

Generics fundamentals: a practical guide for developers

When you write functions or classes that need to work with multiple types while keeping type safety intact, generics are your foundation. They let you define reusable code that adapts to whatever type the caller provides—without sacrificing the precision that makes TypeScript valuable. In this guide, we'll walk through how to think about and use generics effectively in production code.

Why generics matter

Before diving into the mechanics, let's ground this in reality. You're building an API client that needs to handle JSON responses of different shapes. You're writing a data structure utility that should work with numbers, strings, or custom objects. You're creating a retry mechanism that wraps any async operation. In each case, you could write the same logic multiple times for each type—or you could write it once, correctly, with generics.

Without generics, you'd either sacrifice type safety by using any, or duplicate logic across functions. Neither is acceptable in a codebase we maintain for years. Generics bridge that gap: they're type parameters that let a single function or class accept and return different types while keeping full type checking.

Type parameters: the foundation

A type parameter is a placeholder for a type that gets filled in when the function or class is called. The most common syntax uses angle brackets: <T>, where T is conventionally "Type" (though any name works).

typescript
function first<T>(xs: T[]): T | undefined {
  return xs[0];
}

const n = first([1, 2, 3]);           // T = number; result is number | undefined
const s = first(['a', 'b']);         // T = string; result is string | undefined
const nothing = first([]);           // T = unknown; result is unknown | undefined

Here, T is bound to whatever type the array contains. When we call first([1, 2, 3]), TypeScript infers T as number. The function signature becomes (xs: number[]): number | undefined for that call. This happens automatically—no manual type annotation needed.

Multiple type parameters are equally valid and useful:

typescript
function pair<T, U>(a: T, b: U): [T, U] {
  return [a, b];
}

const result = pair(42, 'hello');  // [number, string]
const result2 = pair(true, { id: 1 });  // [boolean, { id: number }]

Here T and U are independent. Each can be any type, and TypeScript tracks both separately.

Inference: let TypeScript do the work

In most cases, you don't explicitly write the type parameter. TypeScript infers it from how you call the function. This is powerful because it reduces noise and keeps code readable.

typescript
function getOrDefault<T>(value: T | null, fallback: T): T {
  return value ?? fallback;
}

// TypeScript infers T = string from the arguments
const greeting = getOrDefault('hello', 'world');

// TypeScript infers T = number
const count = getOrDefault(null, 0);

// TypeScript infers T = { name: string }
const user = getOrDefault(null, { name: 'Alice' });

Inference becomes even more useful in complex scenarios. Consider a promise wrapper:

typescript
function wrap<T>(value: T): Promise<T> {
  return Promise.resolve(value);
}

const p1 = wrap(42);         // Promise<number>
const p2 = wrap(['a', 'b']); // Promise<string[]>
const p3 = wrap({ id: 1 });  // Promise<{ id: number }>

TypeScript saw the argument and propagated its type through to the return type. We didn't write any type annotations—inference did it all. This is inference at its best: it reduces boilerplate while keeping type safety crystal clear.

Sometimes, though, inference isn't enough, and you need to be explicit:

typescript
function create<T>(): T {
  return undefined as T;  // Honest lie: we're stubbing
}

// TypeScript can't infer T from no arguments
// So we pass it explicitly
const x: string = create<string>();
const y: number = create<number>();

When there's no argument to infer from, explicit type parameters are necessary and expected.

Generic types: wrapping any payload

Generics aren't just for functions. They're equally powerful on types and classes. A common real-world pattern is a wrapper type that holds any kind of data—think API responses, cache entries, or database results.

typescript
type ApiResponse<T> = {
  status: 'success' | 'error';
  data: T;
  timestamp: number;
};

// Each use specializes T to a concrete type
type UserResponse = ApiResponse<{ id: number; name: string }>;
type ListResponse = ApiResponse<{ items: string[]; count: number }>;

const response1: UserResponse = {
  status: 'success',
  data: { id: 1, name: 'Alice' },
  timestamp: Date.now(),
};

const response2: ListResponse = {
  status: 'success',
  data: { items: ['a', 'b'], count: 2 },
  timestamp: Date.now(),
};

This pattern scales beautifully to classes:

typescript
class Repository<T> {
  private cache: Map<string, T> = new Map();

  set(key: string, value: T): void {
    this.cache.set(key, value);
  }

  get(key: string): T | undefined {
    return this.cache.get(key);
  }
}

const userRepo = new Repository<{ id: number; name: string }>();
userRepo.set('user:1', { id: 1, name: 'Bob' });

const user = userRepo.get('user:1');  // { id: number; name: string } | undefined

const numberRepo = new Repository<number>();
numberRepo.set('counter', 42);
const count = numberRepo.get('counter');  // number | undefined

Each instance of Repository is bound to a single type. The class's methods all work with that type consistently. This is type safety without repetition.

Constraints: limiting what types can be used

Sometimes you need to guarantee that a type parameter has certain properties. This is where constraints come in. A constraint narrows what types are valid.

typescript
function getId<T extends { id: number }>(obj: T): number {
  return obj.id;
}

getId({ id: 1, name: 'Alice' });           // OK
getId({ id: 2 });                          // OK
// getId('string');                        // Error: string doesn't have id: number

The extends keyword says: T can be any type, as long as it has an id property of type number. This lets you safely access obj.id inside the function.

Constraints are invaluable for preventing bugs and making intent clear:

typescript
function getLength<T extends { length: number }>(value: T): number {
  return value.length;
}

getLength('hello');       // OK: strings have length
getLength([1, 2, 3]);     // OK: arrays have length
getLength({ length: 5 }); // OK: any object with length property
// getLength(42);         // Error: numbers don't have length

You can also constrain to built-in types or unions:

typescript
function toArray<T extends string | number>(value: T): T[] {
  return [value];
}

toArray('hello');      // OK
toArray(42);           // OK
// toArray(true);      // Error: boolean is not string | number

Keep it simple: one or two type parameters

It's tempting to write functions with many type parameters, each with complex constraints. Resist that impulse. Code with too many type parameters becomes harder to read, harder to use, and harder to debug.

A good rule of thumb: one or two type parameters covers most real-world needs. Beyond that, you're usually overengineering.

typescript
// Good: clear and simple
function map<T, U>(xs: T[], fn: (x: T) => U): U[] {
  return xs.map(fn);
}

const nums = [1, 2, 3];
const strs = map(nums, n => n.toString());  // string[]

// Harder to follow and rarely necessary
function overcomplicated<T, U, V, W, X>(
  a: T,
  b: U extends T ? U : never,
  c: (x: V) => W extends X ? W : never,
  ...
): ... {
  // This is confusing and usually indicates design issues
}

When you find yourself writing a function with three or more type parameters, step back. Often, you can simplify by adjusting the function's shape or by moving complexity into a helper type or intermediate step.

Common mistakes

Overusing any inside generics: If you write function process<T>(x: T): any { ... }, the return type loses type information. Either return T, a transformation of T, or a concrete type. Returning any defeats the purpose of using a generic.

Forgetting inference exists: Many developers write first<number>([1, 2, 3]) when first([1, 2, 3]) is cleaner and equally type-safe. Explicit type parameters are useful only when inference can't work.

Generic bloat: Adding type parameters "just in case" makes code harder to read and use. Only add a type parameter if it's actually needed for flexibility or reuse.

Ignoring constraints: If your generic function assumes a property exists, use a constraint. Don't silently rely on runtime behavior. Constraints make your intent visible and catch misuse at type-check time.

Mixing inference and explicit parameters: In some cases, you might pass one type parameter explicitly and rely on inference for the rest. This is valid but can be confusing. Be consistent within your codebase.

Key takeaways

  • Generics fundamentals center on type parameters: placeholders that let functions and types adapt to any input while keeping type safety.
  • TypeScript infers type parameters from arguments in most cases, reducing boilerplate without sacrificing precision.
  • Use generic types like ApiResponse<T> to wrap any payload. Use generic classes like Repository<T> to build reusable data structures.
  • Constraints narrow what types are valid, letting you safely assume certain properties or methods exist.
  • Keep the number of type parameters small—one or two is usually enough. More than that signals overengineering.
  • Inference + constraints + simple shapes = readable, maintainable generics that actually get used by teammates.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles