DevToolBoxGRATIS
Blog

TypeScript Generics Complete Gids 2026: Van Basis tot Geavanceerde Patronen

14 min lezenby DevToolBox

TL;DR

TypeScript generics are type-level functions: <T> is a parameter filled in at the call site. Use constraints (T extends X) to require properties; use conditional types (T extends U ? X : Y) to compute types at compile time; use mapped types to transform object shapes; use template literal types to type string patterns. Master the built-in utility types (Partial, Required, Record, ReturnType, Awaited) β€” they are all implemented with generics. Convert your TypeScript to JavaScript instantly with our TypeScript to JavaScript converter.

Generic Functions β€” Syntax, Inference, and Multiple Type Parameters

A generic function defers the decision about which type to use to the caller. The type parameter <T> is captured at the call site through type inference β€” you rarely need to pass it explicitly.

// Basic generic function
function identity<T>(arg: T): T {
  return arg;
}

const str = identity('hello');   // T inferred as string
const num = identity(42);        // T inferred as number
const explicit = identity<boolean>(true); // explicit type argument

// Multiple type parameters
function pair<A, B>(first: A, second: B): [A, B] {
  return [first, second];
}
const p = pair('name', 42); // [string, number]

// Generic with return type inferred
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
const head = first([1, 2, 3]); // number | undefined

// Generic arrow functions (TSX files need the trailing comma)
const mapArr = <T, U>(arr: T[], fn: (item: T) => U): U[] => arr.map(fn);

// Generic with default type parameter (TS 2.3+)
function createState<T = string>(initial: T): { value: T; set: (v: T) => void } {
  let value = initial;
  return { value, set: (v) => { value = v; } };
}
const s1 = createState();        // T defaults to string
const s2 = createState(0);       // T inferred as number

Generic Interfaces and Classes β€” Stack, Queue, Repository

Generic interfaces and classes allow you to create reusable data structures and services that work with any type while maintaining full type safety.

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

// Generic Stack<T>
class Stack<T> {
  private items: T[] = [];

  push(item: T): void { this.items.push(item); }
  pop(): T | undefined { return this.items.pop(); }
  peek(): T | undefined { return this.items[this.items.length - 1]; }
  isEmpty(): boolean { return this.items.length === 0; }
  size(): number { return this.items.length; }
}

const numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
const top = numStack.pop(); // number | undefined

// Generic Queue<T>
class Queue<T> {
  private items: T[] = [];

  enqueue(item: T): void { this.items.push(item); }
  dequeue(): T | undefined { return this.items.shift(); }
  front(): T | undefined { return this.items[0]; }
  size(): number { return this.items.length; }
}

// User type for examples
interface User { id: number; name: string; email: string; }

// Generic Repository implementation
class UserRepository implements Repository<User, number> {
  private store = new Map<number, User>();

  async findById(id: number): Promise<User | null> {
    return this.store.get(id) ?? null;
  }
  async findAll(): Promise<User[]> { return [...this.store.values()]; }
  async save(user: User): Promise<User> {
    this.store.set(user.id, user);
    return user;
  }
  async delete(id: number): Promise<void> { this.store.delete(id); }
}

// Generic class with constructor constraint
function createInstance<T>(ctor: new () => T): T {
  return new ctor();
}
class Dog { bark() { return 'woof'; } }
const dog = createInstance(Dog); // Dog β€” fully typed

Constraints β€” extends, keyof, and Bounded Type Parameters

Constraints narrow what types are acceptable for a type parameter. The extends keyword creates an upper bound.

// Basic constraint β€” T must have a length property
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}
longest('hello', 'hi');        // string β€” OK
longest([1, 2, 3], [4, 5]);    // number[] β€” OK
// longest(1, 2);              // Error: number has no 'length'

// keyof constraint β€” K must be a key of T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
const userObj = { name: 'Alice', age: 30 };
const uname = getProperty(userObj, 'name');  // string
const uage  = getProperty(userObj, 'age');   // number
// getProperty(userObj, 'email');            // Error: not a key

// T extends object β€” exclude primitives
function objectKeys<T extends object>(obj: T): (keyof T)[] {
  return Object.keys(obj) as (keyof T)[];
}

// T extends keyof U β€” key of another type parameter
function pickFields<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  return keys.reduce((acc, key) => {
    acc[key] = obj[key];
    return acc;
  }, {} as Pick<T, K>);
}

// Multiple constraints using intersection
function mergeObjects<T extends object, U extends object>(a: T, b: U): T & U {
  return { ...a, ...b };
}

// Constraint with constructor
function build<T>(ctor: new () => T): T {
  return new ctor();
}

Conditional Types β€” infer, Distributive Behavior, and NonNullable

Conditional types compute a type based on a type-level condition: T extends U ? X : Y. They are evaluated at compile time and are the basis for most advanced utility types.

// Basic conditional type
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>;      // false

// infer keyword β€” extract a type from a pattern
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
type Fn = (x: number) => string;
type R = MyReturnType<Fn>; // string

type ElementType<T> = T extends (infer E)[] ? E : T;
type E1 = ElementType<number[]>; // number
type E2 = ElementType<string>;   // string (not an array, returns T itself)

// Awaited β€” recursively unwrap promises
type MyAwaited<T> = T extends Promise<infer U> ? MyAwaited<U> : T;
type Resolved = MyAwaited<Promise<Promise<number>>>; // number

// Distributive conditional types β€” distribute over unions
type ToArray<T> = T extends unknown ? T[] : never;
type StrOrNumArr = ToArray<string | number>; // string[] | number[]

// Prevent distribution with wrapping in a tuple
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type Combined = ToArrayNonDist<string | number>; // (string | number)[]

// NonNullable β€” removes null and undefined
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Safe = MyNonNullable<string | null | undefined>; // string

// Complex infer β€” extract promise resolve type
type UnwrapPromise<T> =
  T extends Promise<infer U> ? U :
  T extends PromiseLike<infer U> ? U :
  T;

Mapped Types β€” Transform Object Shapes

Mapped types iterate over the keys of a type and produce a new object type. All of TypeScript's built-in structural utility types (Partial, Required, Readonly, Record) are mapped types.

// Built-in mapped types (their implementations)
type MyPartial<T>  = { [K in keyof T]?: T[K] };          // all optional
type MyRequired<T> = { [K in keyof T]-?: T[K] };         // all required (-? removes ?)
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };  // all readonly
type MyRecord<K extends string, V> = { [P in K]: V };    // key-value map

// Custom: make specific keys optional
type PartialBy<T, K extends keyof T> =
  Omit<T, K> & Partial<Pick<T, K>>;

interface UserType { id: number; name: string; email: string; }
type UserUpdate = PartialBy<UserType, 'name' | 'email'>;
// { id: number; name?: string; email?: string }

// Custom: make all values nullable
type Nullable<T> = { [K in keyof T]: T[K] | null };

// Custom: deep partial
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

// Key remapping with 'as' clause (TS 4.1+)
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<UserType>;
// { getId: () => number; getName: () => string; getEmail: () => string }

// Filter keys by value type using 'as never'
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
type StringFields = OnlyStrings<{ id: number; name: string; email: string }>;
// { name: string; email: string }

// Mutable<T> β€” remove readonly
type Mutable<T> = { -readonly [K in keyof T]: T[K] };

Template Literal Types β€” String Pattern Typing

Template literal types (TypeScript 4.1+) construct string types using backtick syntax. When unions appear in template positions, TypeScript automatically distributes to produce all permutations.

// Basic template literal type
type Greeting = `Hello, ${string}`;
const g1: Greeting = 'Hello, Alice';  // OK

// Union distribution β€” cartesian product
type Direction = 'top' | 'right' | 'bottom' | 'left';
type CSSPadding = `padding-${Direction}`;
// "padding-top" | "padding-right" | "padding-bottom" | "padding-left"

// EventHandler<T> β€” typed event names
type EventHandler<T extends string> = `on${Capitalize<T>}`;
type ClickHandler = EventHandler<'click'>; // "onClick"
type InputEvents = EventHandler<'change' | 'blur' | 'focus'>;
// "onChange" | "onBlur" | "onFocus"

// API path pattern
type ApiVersion = 'v1' | 'v2';
type Resource   = 'users' | 'posts' | 'comments';
type ApiPath    = `/api/${ApiVersion}/${Resource}`;
// "/api/v1/users" | "/api/v1/posts" | ... (6 combinations)

// Intrinsic string manipulation types
type UpperHello = Uppercase<'hello'>;      // "HELLO"
type LowerHELLO = Lowercase<'HELLO'>;      // "hello"
type CapHello   = Capitalize<'hello'>;     // "Hello"
type UncapHello = Uncapitalize<'Hello'>;   // "hello"

// Deep key paths with template literals
type Paths<T extends object, Prefix extends string = ''> = {
  [K in keyof T & string]:
    T[K] extends object
      ? Paths<T[K], `${Prefix}${K}.`> | `${Prefix}${K}`
      : `${Prefix}${K}`;
}[keyof T & string];

type Config = { db: { host: string; port: number }; port: number };
type ConfigPaths = Paths<Config>;
// "db" | "db.host" | "db.port" | "port"

Utility Types Deep Dive β€” Awaited, Parameters, ReturnType, InstanceType

TypeScript ships with a rich set of built-in generic utility types. Here are the ones most developers underutilize:

// Parameters<T> β€” tuple of function parameter types
type SomeFn = (a: string, b: number, c: boolean) => void;
type Params = Parameters<SomeFn>; // [string, number, boolean]

// ReturnType<T> β€” function return type
type ParseResult = ReturnType<typeof JSON.parse>; // any

interface UserType { id: string; name: string; }
declare function fetchUser(id: string): Promise<UserType>;
type FetchReturn   = ReturnType<typeof fetchUser>;   // Promise<UserType>
type ResolvedUser  = Awaited<ReturnType<typeof fetchUser>>; // UserType

// ConstructorParameters<T> β€” constructor parameter types
class HttpClient {
  constructor(public baseUrl: string, public timeout: number) {}
}
type CtorArgs = ConstructorParameters<typeof HttpClient>; // [string, number]

// InstanceType<T> β€” type returned by new T()
type ClientInstance = InstanceType<typeof HttpClient>; // HttpClient

// OmitThisParameter<T> β€” removes 'this' parameter
function greetUser(this: { name: string }, greeting: string): string {
  return `${greeting}, ${this.name}!`;
}
type GreetFn = OmitThisParameter<typeof greetUser>; // (greeting: string) => string

// Awaited<T> β€” recursive promise unwrap (TS 4.5+)
type DeepPromise = Promise<Promise<Promise<number>>>;
type Flat = Awaited<DeepPromise>; // number

// Extract<T, U> and Exclude<T, U>
type Status = 'active' | 'inactive' | 'banned' | 'pending';
type ActiveStates   = Extract<Status, 'active' | 'pending'>;   // "active" | "pending"
type NegativeStates = Exclude<Status, 'active' | 'pending'>;   // "inactive" | "banned"

// NonNullable removes null and undefined from a union
type MaybeString = string | null | undefined;
type DefiniteString = NonNullable<MaybeString>; // string

Variance β€” Covariance, Contravariance, and in/out Modifiers (TS 4.7)

Variance determines how subtype relationships flow through generic types. Understanding it prevents subtle type safety bugs.

// Setup: Animal <- Dog subtype hierarchy
class Animal { breathe(): void {} }
class Dog extends Animal { bark(): void {} }

// COVARIANCE β€” subtype relationship preserved
// Producer<Dog> is assignable to Producer<Animal>
interface Producer<out T> { get(): T; }
const produceDog:    Producer<Dog>    = { get: () => new Dog() };
const produceAnimal: Producer<Animal> = produceDog; // OK

// CONTRAVARIANCE β€” relationship reversed
// Consumer<Animal> is assignable to Consumer<Dog>
interface Consumer<in T> { set(arg: T): void; }
const consumeAnimal: Consumer<Animal> = { set: (a) => a.breathe() };
const consumeDog:    Consumer<Dog>    = consumeAnimal; // OK

// Why function params are contravariant (strictFunctionTypes)
// A handler that accepts Animal also works for Dog (Dog is-a Animal)
type Handler<T> = (event: T) => void;

// INVARIANCE β€” mutable containers
// Array<Dog> is NOT safely assignable to Array<Animal>
// (you could push a Cat into it)

// TypeScript 4.7 explicit variance annotations
interface Getter<out T> {
  get(): T;         // T only in output position β€” covariant
}

interface Setter<in T> {
  set(value: T): void; // T only in input position β€” contravariant
}

interface ReadWrite<in out T> { // invariant
  get(): T;
  set(value: T): void;
}

// Variance in practice β€” function composition
function compose<A, B, C>(
  f: (a: A) => B,
  g: (b: B) => C
): (a: A) => C {
  return (a) => g(f(a));
}

Generic Type Inference β€” Contextual Typing and Template Literal Inference

TypeScript's type inference engine can infer generic type parameters from context, making explicit type annotations rare in well-typed code.

// Contextual typing β€” callback param types inferred from generic
const numbers: number[] = [3, 1, 2];
const sorted = numbers.sort((a, b) => a - b); // a and b are number

// Inference from multiple call sites
function mergeTwo<T>(a: T, b: T): T {
  return { ...(a as object), ...(b as object) } as T;
}
// T inferred as { name: string } β€” best common type
const merged = mergeTwo({ name: 'Alice' }, { name: 'Bob' });

// infer in conditional types
type FirstParam<T> = T extends (first: infer F, ...rest: unknown[]) => unknown ? F : never;
type FP = FirstParam<(x: string, y: number) => void>; // string

// Template literal inference
type ParseRoute<T extends string> =
  T extends `${infer Prefix}/${infer Param}`
    ? { prefix: Prefix; param: Param }
    : { prefix: T; param: never };

type Route = ParseRoute<'/api/users'>; // { prefix: '/api'; param: 'users' }

// Inferring tuple types
type Head<T extends unknown[]> = T extends [infer H, ...unknown[]] ? H : never;
type Tail<T extends unknown[]> = T extends [unknown, ...infer R] ? R : never;
type Last<T extends unknown[]> = T extends [...unknown[], infer L] ? L : never;

type H = Head<[string, number, boolean]>; // string
type TailType = Tail<[string, number, boolean]>; // [number, boolean]
type L = Last<[string, number, boolean]>; // boolean

// Const assertion for narrowed literal inference
function createActions<const T extends readonly string[]>(actions: T): T {
  return actions;
}
const myActions = createActions(['add', 'remove', 'update'] as const);
// readonly ["add", "remove", "update"] β€” not string[]

Real-World Patterns β€” Typed API Client, Event Emitter, Builder, Type-Safe Forms

These patterns demonstrate how generics combine with other TypeScript features to solve real engineering problems with complete type safety.

Typed API Client

interface PostType { id: string; title: string; }

// Define endpoint schema
interface ApiSchema {
  '/users':     { GET: UserType[];  POST: UserType };
  '/users/:id': { GET: UserType;   PUT: UserType; DELETE: void };
  '/posts':     { GET: PostType[]; POST: PostType };
}

type Endpoint = keyof ApiSchema;
type HttpMethod<P extends Endpoint> = keyof ApiSchema[P] & string;
type ApiResponse<P extends Endpoint, M extends HttpMethod<P>> =
  ApiSchema[P][M extends keyof ApiSchema[P] ? M : never];

async function apiRequest<P extends Endpoint, M extends HttpMethod<P>>(
  path: P,
  method: M,
  body?: unknown
): Promise<ApiResponse<P, M>> {
  const res = await fetch(path.toString(), {
    method,
    body: body ? JSON.stringify(body) : undefined,
    headers: { 'Content-Type': 'application/json' },
  });
  return res.json() as Promise<ApiResponse<P, M>>;
}

// Usage
const users = await apiRequest('/users', 'GET');      // UserType[]
const oneUser = await apiRequest('/users/:id', 'GET'); // UserType

Typed Event Emitter

interface AppEvents {
  'user:login':   { userId: string; timestamp: number };
  'user:logout':  { userId: string };
  'post:created': { postId: string; authorId: string };
  'error':        { message: string; code: number };
}

class TypedEventEmitter<Events extends Record<string, unknown>> {
  private listeners = new Map<keyof Events, Set<(data: unknown) => void>>();

  on<K extends keyof Events>(event: K, handler: (data: Events[K]) => void): () => void {
    if (!this.listeners.has(event)) this.listeners.set(event, new Set());
    const fn = handler as (data: unknown) => void;
    this.listeners.get(event)!.add(fn);
    return () => this.listeners.get(event)!.delete(fn);
  }

  emit<K extends keyof Events>(event: K, data: Events[K]): void {
    this.listeners.get(event)?.forEach(handler => handler(data));
  }
}

const emitter = new TypedEventEmitter<AppEvents>();

emitter.on('user:login', ({ userId, timestamp }) => {
  console.log(`User ${userId} logged in at ${timestamp}`);
});

emitter.emit('user:login', { userId: '123', timestamp: Date.now() });

Builder Pattern with Type Tracking

// Type-safe query builder
interface QueryState {
  table: string | null;
  conditions: string[];
  columns: string[];
  limitVal: number | null;
}

class QueryBuilder<HasTable extends boolean = false> {
  private state: QueryState = {
    table: null, conditions: [], columns: [], limitVal: null
  };

  from(tableName: string): QueryBuilder<true> {
    this.state.table = tableName;
    return this as unknown as QueryBuilder<true>;
  }

  select(...cols: string[]): this {
    this.state.columns = cols;
    return this;
  }

  where(condition: string): this {
    this.state.conditions.push(condition);
    return this;
  }

  limit(n: number): this {
    this.state.limitVal = n;
    return this;
  }

  // Only callable when HasTable extends true
  build(this: QueryBuilder<true>): string {
    const cols = this.state.columns.length ? this.state.columns.join(', ') : '*';
    const where = this.state.conditions.length
      ? ` WHERE ${this.state.conditions.join(' AND ')}`
      : '';
    const lim = this.state.limitVal !== null ? ` LIMIT ${this.state.limitVal}` : '';
    return `SELECT ${cols} FROM ${this.state.table}${where}${lim}`;
  }
}

const query = new QueryBuilder()
  .from('users')
  .select('id', 'name')
  .where('age > 18')
  .limit(10)
  .build(); // "SELECT id, name FROM users WHERE age > 18 LIMIT 10"

Type-Safe Form Handling with Zod Inference

// Without Zod β€” manual approach
interface LoginForm {
  email: string;
  password: string;
  remember: boolean;
}

// Generic form validator
type ValidationErrors<T> = Partial<Record<keyof T, string>>;

function validateForm<T extends Record<string, unknown>>(
  data: unknown,
  rules: { [K in keyof T]: (val: unknown) => string | null }
): { valid: true; data: T } | { valid: false; errors: ValidationErrors<T> } {
  const errors: ValidationErrors<T> = {};
  const result = {} as T;

  for (const key in rules) {
    const val = (data as Record<string, unknown>)[key];
    const error = rules[key](val);
    if (error) {
      errors[key] = error;
    } else {
      result[key] = val as T[typeof key];
    }
  }

  const hasErrors = Object.keys(errors).length > 0;
  return hasErrors
    ? { valid: false, errors }
    : { valid: true, data: result };
}

// Usage
const result = validateForm<LoginForm>(
  { email: 'alice@example.com', password: 'hunter2!' },
  {
    email: (v) => typeof v === 'string' && v.includes('@') ? null : 'Invalid email',
    password: (v) => typeof v === 'string' && v.length >= 8 ? null : 'Min 8 chars',
    remember: () => null,
  }
);

if (result.valid) {
  console.log(result.data.email); // string β€” fully typed
}

TypeScript Built-in Utility Types β€” Quick Reference

All of these are defined in TypeScript's lib.es5.d.ts using the generic type features covered in this guide.

Utility TypeTransformsExample
Partial<T>All properties optionalPartial<User> β†’ all fields ?
Required<T>All properties requiredRemoves all ? modifiers
Readonly<T>All properties readonlyPrevents mutation after creation
Record<K, V>Map K keys to V valuesRecord<string, number>
Pick<T, K>Keep only keys K from TPick<User, "id" | "name">
Omit<T, K>Remove keys K from TOmit<User, "password">
Extract<T, U>Keep union members in UExtract<"a"|"b"|"c", "a"|"c"> β†’ "a"|"c"
Exclude<T, U>Remove union members in UExclude<string|null, null> β†’ string
NonNullable<T>Remove null and undefinedNonNullable<string | null> β†’ string
ReturnType<T>Return type of function TReturnType<typeof fetch>
Parameters<T>Tuple of function param typesParameters<typeof console.log>
Awaited<T>Unwrap nested PromisesAwaited<Promise<Promise<number>>> β†’ number

Recursive Generic Types β€” JSON, Tree, and Deep Transformations

TypeScript supports recursive type aliases, enabling you to describe types like JSON values, nested trees, and deeply nested transformations.

// Type-safe JSON
type JSONPrimitive = string | number | boolean | null;
type JSONObject = { [key: string]: JSONValue };
type JSONArray  = JSONValue[];
type JSONValue  = JSONPrimitive | JSONObject | JSONArray;

const json: JSONValue = {
  name: 'Alice',
  scores: [100, 200, 300],
  meta: { active: true, tags: ['admin', 'user'] },
};

// Binary tree
interface TreeNode<T> {
  value: T;
  left:  TreeNode<T> | null;
  right: TreeNode<T> | null;
}

function treeMap<T, U>(node: TreeNode<T> | null, fn: (val: T) => U): TreeNode<U> | null {
  if (!node) return null;
  return {
    value: fn(node.value),
    left:  treeMap(node.left, fn),
    right: treeMap(node.right, fn),
  };
}

// Deep readonly β€” makes all nested properties readonly
type DeepReadonly<T> =
  T extends (infer U)[]
    ? ReadonlyArray<DeepReadonly<U>>
    : T extends object
      ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
      : T;

interface Config {
  server: { host: string; port: number };
  db:     { url: string; pool: { min: number; max: number } };
}

type ImmutableConfig = DeepReadonly<Config>;
// All nested fields are readonly β€” even deeply nested port and min/max

// Deep flatten β€” flatten all nested arrays
type Flatten<T> = T extends (infer U)[] ? Flatten<U> : T;
type F = Flatten<number[][][]>; // number

Discriminated Unions with Generics β€” Result and Option Types

Combining discriminated unions with generics produces powerful, type-safe result types that eliminate runtime errors by making error handling mandatory at the type level.

// Result<T, E> β€” explicit success or failure
type Result<T, E = Error> =
  | { ok: true;  value: T }
  | { ok: false; error: E };

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) return { ok: false, error: 'Division by zero' };
  return { ok: true, value: a / b };
}

const r = divide(10, 2);
if (r.ok) {
  console.log(r.value); // number β€” TypeScript knows this is the success case
} else {
  console.error(r.error); // string
}

// Option<T> β€” nullable without null (like Rust's Option<T>)
type Option<T> =
  | { some: true;  value: T }
  | { some: false };

const none: Option<never> = { some: false };
function some<T>(value: T): Option<T> { return { some: true, value }; }

function findUser(id: string): Option<UserType> {
  const users: UserType[] = [{ id: '1', name: 'Alice', email: 'a@b.com' }];
  const user = users.find(u => u.id === id);
  return user ? some(user) : none;
}

const maybeUser = findUser('1');
if (maybeUser.some) {
  console.log(maybeUser.value.name); // TypeScript narrows to { some: true; value: UserType }
}

// Generic Result helpers
function mapResult<T, U, E>(result: Result<T, E>, fn: (val: T) => U): Result<U, E> {
  return result.ok ? { ok: true, value: fn(result.value) } : result;
}

function flatMapResult<T, U, E>(
  result: Result<T, E>,
  fn: (val: T) => Result<U, E>
): Result<U, E> {
  return result.ok ? fn(result.value) : result;
}

tsconfig Settings That Affect Generic Behavior

Several TypeScript compiler options change how generics are checked and inferred. Understanding these settings helps you get the most out of the type system.

OptionEffect on Generics
strictEnables strictFunctionTypes, strictNullChecks β€” essential for correct variance and null-safety in generics
strictFunctionTypesEnforces contravariance for function parameters β€” prevents unsafe function type substitution
noImplicitAnyForces explicit generic type arguments when inference would resolve to any
useUnknownInCatchVariablesMakes catch clause error type unknown instead of any β€” safer for generic error types
exactOptionalPropertyTypesDistinguishes undefined from missing key in mapped types with optional properties

Common Generic Pitfalls and How to Avoid Them

// ❌ PITFALL 1: Using 'any' instead of generics
function badIdentity(arg: any): any { return arg; }
const r1 = badIdentity('hello'); // r1 is 'any' β€” type info lost

// βœ… FIX: Use a generic
function goodIdentity<T>(arg: T): T { return arg; }
const r2 = goodIdentity('hello'); // r2 is 'string' β€” preserved

// ❌ PITFALL 2: Over-constraining with specific types
function processItems(items: string[]): string[] {
  return items.map(i => i.toUpperCase());
}
// This ONLY works for strings β€” not reusable

// βœ… FIX: Use generics with minimal constraints
function transformItems<T>(items: T[], transform: (item: T) => T): T[] {
  return items.map(transform);
}

// ❌ PITFALL 3: Forgetting distributive behavior of conditional types
type Wrap<T> = T extends unknown ? { value: T } : never;
type Result = Wrap<string | number>;
// Result = { value: string } | { value: number }  (distributed!)
// NOT { value: string | number }

// βœ… FIX: Wrap in tuple to prevent distribution
type WrapUnion<T> = [T] extends [unknown] ? { value: T } : never;
type Result2 = WrapUnion<string | number>;
// { value: string | number }

// ❌ PITFALL 4: Using generic type parameter in wrong position
interface BadContainer<T> {
  items: T[];
  addItem(item: string): void; // T not used β€” should be T
}

// βœ… FIX: Use the type parameter consistently
interface GoodContainer<T> {
  items: T[];
  addItem(item: T): void;
}

// ❌ PITFALL 5: Calling .toUpperCase() without a string constraint
function upper<T>(s: T): string {
  return (s as unknown as string).toUpperCase(); // unsafe cast
}

// βœ… FIX: Constrain T to string
function upperStr<T extends string>(s: T): Uppercase<T> {
  return s.toUpperCase() as Uppercase<T>;
}

Generic Higher-Order Functions β€” memoize, retry, and debounce

Higher-order functions (functions that take or return functions) are a natural fit for generics because they need to preserve the type signatures of the functions they wrap.

// Typed memoize β€” preserves the function signature exactly
function memoize<T extends (...args: unknown[]) => unknown>(fn: T): T {
  const cache = new Map<string, ReturnType<T>>();
  return ((...args: Parameters<T>): ReturnType<T> => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key)!;
    const result = fn(...args) as ReturnType<T>;
    cache.set(key, result);
    return result;
  }) as T;
}

function expensiveCalc(n: number): number { return n * n; }
const memoCalc = memoize(expensiveCalc);
memoCalc(5); // number β€” return type preserved

// Typed retry β€” retries an async function N times on failure
async function retry<T>(
  fn: () => Promise<T>,
  retries: number,
  delayMs = 1000
): Promise<T> {
  try {
    return await fn();
  } catch (err) {
    if (retries <= 0) throw err;
    await new Promise(resolve => setTimeout(resolve, delayMs));
    return retry(fn, retries - 1, delayMs * 2);
  }
}

const data = await retry(() => fetch('/api/data').then(r => r.json()), 3);
// data is 'any' from fetch β€” but works with typed fetch wrappers too

// Typed debounce β€” preserves parameter types
function debounce<T extends (...args: unknown[]) => void>(
  fn: T,
  waitMs: number
): (...args: Parameters<T>) => void {
  let timer: ReturnType<typeof setTimeout>;
  return (...args: Parameters<T>) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), waitMs);
  };
}

function onSearch(query: string, page: number): void {
  console.log(`Searching: ${query} page ${page}`);
}
const debouncedSearch = debounce(onSearch, 300);
debouncedSearch('hello', 1); // (query: string, page: number) => void β€” typed!

// Typed once β€” run function at most once
function once<T extends (...args: unknown[]) => unknown>(fn: T): T {
  let called = false;
  let result: ReturnType<T>;
  return ((...args: Parameters<T>): ReturnType<T> => {
    if (!called) {
      called = true;
      result = fn(...args) as ReturnType<T>;
    }
    return result;
  }) as T;
}

const initApp = once((port: number) => {
  console.log(`Starting on port ${port}`);
  return { port };
});
initApp(3000); // runs β€” returns { port: number }
initApp(4000); // no-op β€” returns cached result

Generic Middleware Pipeline β€” Type-Safe Composition

// Type-safe pipe β€” compose functions left to right
type Pipe<T extends unknown[], R> =
  T extends [infer First, ...infer Rest]
    ? First extends (arg: infer A) => infer B
      ? Rest extends [(arg: B) => unknown, ...unknown[]]
        ? Pipe<Rest, R>
        : never
      : never
    : R;

// Simple runtime pipe for two-step example
function pipe2<A, B, C>(
  fn1: (a: A) => B,
  fn2: (b: B) => C
): (a: A) => C {
  return (a) => fn2(fn1(a));
}

const double    = (n: number): number  => n * 2;
const stringify = (n: number): string  => String(n);
const trim      = (s: string): string  => s.trim();

const doubleThenStringify = pipe2(double, stringify);
const result = doubleThenStringify(21); // "42" β€” string

// Middleware context pattern
interface Context<T extends object> {
  data: T;
  meta: Record<string, unknown>;
}

type Middleware<T extends object, U extends object> =
  (ctx: Context<T>, next: (ctx: Context<U>) => void) => void;

function composeMiddleware<T extends object>(
  middlewares: Middleware<T, T>[]
): (ctx: Context<T>) => void {
  return (ctx) => {
    let index = -1;
    function dispatch(i: number, c: Context<T>): void {
      if (i <= index) throw new Error('next() called multiple times');
      index = i;
      const fn = middlewares[i];
      if (!fn) return;
      fn(c, (next) => dispatch(i + 1, next as Context<T>));
    }
    dispatch(0, ctx);
  };
}

Quick Tool: Convert TypeScript to JavaScript instantly with our TypeScript to JavaScript converter β€” paste your generic TypeScript code and see the compiled output in real time, online and free.

Key Takeaways

  • Generics are type-level functions: <T> is a parameter that captures a type at the call site through inference β€” explicit annotation is rarely needed.
  • Constraints narrow valid types: use T extends SomeType to require properties; use K extends keyof T for type-safe property access.
  • Conditional types compute types: T extends U ? X : Y is type-level if/else; infer extracts types from patterns like return types and array elements.
  • Mapped types transform shapes: iterate over keyof T with modifier changes; use as clause (TS 4.1+) for key remapping and filtering.
  • Template literal types type strings: union distribution produces all permutations, enabling typed route params, event names, and CSS properties.
  • Learn the built-in utility types: Awaited, Parameters, ReturnType, ConstructorParameters, InstanceType, OmitThisParameter, Extract, Exclude.
  • Function parameters are contravariant: a function accepting a base type can substitute for one accepting a subtype with --strictFunctionTypes.
  • Use in/out variance annotations (TS 4.7) for generic interfaces to document intent and enable compiler variance checks.
  • Zod + z.infer eliminates duplicate type definitions: define your schema once and derive both runtime validation and compile-time types from it.
  • Real patterns are composable: typed event emitters, API clients, and builders all combine generics + constraints + conditional types into reusable, type-safe abstractions.
  • Enable strict mode: strictFunctionTypes enforces contravariance for function parameters, catching subtle subtype-safety bugs in generic code.
  • Recursive generics model nested data: use recursive type aliases for JSON, trees, and deep transformations like DeepPartial<T> and DeepReadonly<T>.
  • Higher-order function wrappers preserve types: use Parameters<T> and ReturnType<T> to preserve the wrapped function's full signature in memoize, debounce, and retry utilities.
  • Result<T, E> makes error handling explicit: discriminated union result types force callers to handle both success and failure cases at the type level, eliminating forgotten catch blocks.
𝕏 Twitterin LinkedIn
Was dit nuttig?

Blijf op de hoogte

Ontvang wekelijkse dev-tips en nieuwe tools.

Geen spam. Altijd opzegbaar.

Try These Related Tools

TSJSON to TypeScriptJSTypeScript to JavaScript

Related Articles

TypeScript Generics Uitgelegd: Praktische Gids met Voorbeelden

Beheers TypeScript generics van basis tot geavanceerde patronen.

TypeScript vs JavaScript: Wanneer en hoe te converteren

Praktische gids over wanneer TypeScript naar JavaScript te converteren en andersom. MigratiestrategieΓ«n, tooling, bundelgrootte-impact en teamoverwegingen.

JSON naar TypeScript: Complete gids met voorbeelden

Leer hoe je JSON-data automatisch omzet naar TypeScript-interfaces. Geneste objecten, arrays, optionele velden en best practices.