Przejdź do treści
Technologies

TypeScript Generics - Advanced Types in Practice

Published on:
·4 min read·Author: MDS Software Solutions Group

TypeScript Generics Advanced

technologie

TypeScript Generics - Advanced Types in Practice

Generics are one of the most powerful mechanisms in TypeScript, allowing you to create reusable, type-safe components. In this article, we will dive into advanced techniques for working with generics - from basic generic functions to complex design patterns used in production applications.

Generic Functions#

Generic functions allow you to write code that works with different types while maintaining full type safety. Instead of using any, we define a type parameter that TypeScript infers based on the passed arguments.

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

const str = identity("hello"); // typ: string
const num = identity(42);      // typ: number

// Function with multiple type parameters
function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}

const result = pair("name", 42); // typ: [string, number]

// Generic function with callback
function map<T, U>(array: T[], fn: (item: T, index: number) => U): U[] {
  return array.map(fn);
}

const lengths = map(["hello", "world"], (s) => s.length); // typ: number[]

Generic functions with default types make it easier to use APIs when a specific type is not needed:

function createState<T = string>(initial: T): {
  get: () => T;
  set: (value: T) => void;
} {
  let state = initial;
  return {
    get: () => state,
    set: (value: T) => { state = value; },
  };
}

const stringState = createState("hello");   // T = string (inferred)
const numberState = createState<number>(0); // T = number (explicit)

Generic Classes#

Generic classes allow you to create data structures and services that work with any type while providing full control over types.

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];
  }

  get size(): number {
    return this.items.length;
  }
}

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
const top = numberStack.peek(); // typ: number | undefined

// Generic class with multiple parameters
class KeyValueStore<K extends string | number, V> {
  private store = new Map<K, V>();

  set(key: K, value: V): void {
    this.store.set(key, value);
  }

  get(key: K): V | undefined {
    return this.store.get(key);
  }

  entries(): [K, V][] {
    return Array.from(this.store.entries());
  }
}

const userStore = new KeyValueStore<string, { name: string; age: number }>();
userStore.set("user1", { name: "Jan", age: 30 });

Generic Interfaces#

Generic interfaces define contracts that can be parameterized with types. They are the foundation of many design patterns in TypeScript.

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

// Generic interface with pagination
interface PaginatedResponse<T> {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
  hasNext: boolean;
  hasPrevious: boolean;
}

// Usage
interface User {
  id: string;
  name: string;
  email: string;
}

type UserResponse = ApiResponse<User>;
type UserListResponse = ApiResponse<PaginatedResponse<User>>;

// Generic interface with methods
interface Comparable<T> {
  compareTo(other: T): number;
  equals(other: T): boolean;
}

class Money implements Comparable<Money> {
  constructor(
    public amount: number,
    public currency: string
  ) {}

  compareTo(other: Money): number {
    if (this.currency !== other.currency) {
      throw new Error("Cannot compare different currencies");
    }
    return this.amount - other.amount;
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}

Type Constraints (extends)#

The extends keyword in the context of generics allows you to impose constraints on type parameters, ensuring that the passed type meets specific requirements.

// Constraint to objects with a length property
function logLength<T extends { length: number }>(item: T): T {
  console.log(`Length: ${item.length}`);
  return item;
}

logLength("hello");      // OK - string ma length
logLength([1, 2, 3]);    // OK - array ma length
// logLength(42);         // Błąd - number nie ma length

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

const user = { name: "Jan", age: 30, email: "jan@example.com" };
const name = getProperty(user, "name"); // typ: string
const age = getProperty(user, "age");   // typ: number
// getProperty(user, "phone");          // Błąd - "phone" nie istnieje w User

// Constraint with multiple constraints
interface HasId {
  id: string;
}

interface HasTimestamps {
  createdAt: Date;
  updatedAt: Date;
}

function updateEntity<T extends HasId & HasTimestamps>(
  entity: T,
  updates: Partial<Omit<T, "id" | "createdAt">>
): T {
  return {
    ...entity,
    ...updates,
    updatedAt: new Date(),
  };
}

Conditional Types#

Conditional types allow you to create types that depend on conditions. They work similarly to the ternary operator, but at the type system level.

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

type A = IsString<string>;  // true
type B = IsString<number>;  // false

// Conditional type with infer
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type C = UnwrapPromise<Promise<string>>; // string
type D = UnwrapPromise<number>;          // number

// Nested conditional types
type DeepUnwrap<T> = T extends Promise<infer U>
  ? DeepUnwrap<U>
  : T;

type E = DeepUnwrap<Promise<Promise<Promise<string>>>>; // string

// Distributive conditional types
type NonNullable<T> = T extends null | undefined ? never : T;

type F = NonNullable<string | null | undefined>; // string

// Practical example - extracting types from functions
type FunctionReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type G = FunctionReturnType<() => string>;           // string
type H = FunctionReturnType<(x: number) => boolean>; // boolean

Mapped Types#

Mapped types allow you to transform existing types by iterating over their keys. This is a powerful tool for creating type variants.

// Basic mapped type - all fields optional
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// All fields readonly
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

// Advanced mapped type - nullable fields
type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

// Mapped type with - modifier
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

type Required<T> = {
  [K in keyof T]-?: T[K];
};

// Mapped type with key remapping (as)
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type Setters<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};

interface Person {
  name: string;
  age: number;
}

type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number; }

type PersonSetters = Setters<Person>;
// { setName: (value: string) => void; setAge: (value: number) => void; }

// Filtering keys in mapped types
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

interface Mixed {
  name: string;
  age: number;
  email: string;
  active: boolean;
}

type StringFields = OnlyStrings<Mixed>;
// { name: string; email: string; }

Template Literal Types#

Template literal types allow you to create types based on string templates, which is particularly useful when defining APIs and event handlers.

// Basic template literal types
type EventName = `on${Capitalize<"click" | "focus" | "blur">}`;
// "onClick" | "onFocus" | "onBlur"

// Dynamic event name generation
type DOMEvents = "click" | "mouseover" | "mouseout" | "keydown" | "keyup";
type EventHandler = `on${Capitalize<DOMEvents>}`;

// Template literal with generics
type PropEventType<T extends string> = `${T}Changed`;

type UserEvents = PropEventType<"name" | "email" | "age">;
// "nameChanged" | "emailChanged" | "ageChanged"

// Advanced - parsing template literals
type ExtractRouteParams<T extends string> =
  T extends `${infer _}:${infer Param}/${infer Rest}`
    ? Param | ExtractRouteParams<`/${Rest}`>
    : T extends `${infer _}:${infer Param}`
      ? Param
      : never;

type Params = ExtractRouteParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"

// CSS Unit types
type CSSUnit = "px" | "em" | "rem" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;

const width: CSSValue = "100px";    // OK
const height: CSSValue = "50vh";    // OK
// const bad: CSSValue = "abc";     // Błąd

The infer Keyword#

The infer keyword allows you to extract types from within other types. It is available exclusively inside conditional types and is one of the most powerful tools in the TypeScript type system.

// Extracting element type from array
type ElementType<T> = T extends (infer U)[] ? U : never;

type I = ElementType<string[]>;  // string
type J = ElementType<number[]>;  // number

// Extracting function argument types
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;

type K = FirstArg<(name: string, age: number) => void>; // string

// Extracting type from Promise
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;

// Extracting props from React component
type PropsOf<T> = T extends React.ComponentType<infer P> ? P : never;

// Extracting return type from async function
type AsyncReturnType<T extends (...args: any[]) => Promise<any>> =
  T extends (...args: any[]) => Promise<infer R> ? R : never;

type UserData = AsyncReturnType<() => Promise<{ id: string; name: string }>>;
// { id: string; name: string }

// Infer with template literal types
type ParseQueryString<T extends string> =
  T extends `${infer Key}=${infer Value}&${infer Rest}`
    ? { [K in Key]: Value } & ParseQueryString<Rest>
    : T extends `${infer Key}=${infer Value}`
      ? { [K in Key]: Value }
      : {};

Utility Types - In-Depth Analysis#

TypeScript provides a rich set of built-in utility types. Understanding their implementation allows you to create your own advanced types.

// Partial<T> - all fields optional
type MyPartial<T> = { [K in keyof T]?: T[K] };

// Required<T> - all fields required
type MyRequired<T> = { [K in keyof T]-?: T[K] };

// Pick<T, K> - select a subset of keys
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };

// Omit<T, K> - omit selected keys
type MyOmit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

// Record<K, V> - type with keys K and values V
type MyRecord<K extends keyof any, V> = { [P in K]: V };

// Extract<T, U> - extract types from union matching U
type MyExtract<T, U> = T extends U ? T : never;

// Exclude<T, U> - exclude types from union matching U
type MyExclude<T, U> = T extends U ? never : T;

// ReturnType<T> - function return type
type MyReturnType<T extends (...args: any[]) => any> =
  T extends (...args: any[]) => infer R ? R : any;

// Parameters<T> - function parameter types as tuple
type MyParameters<T extends (...args: any[]) => any> =
  T extends (...args: infer P) => any ? P : never;

Practical applications of utility types:

interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
  category: string;
  inStock: boolean;
  createdAt: Date;
}

// Type for creating a new product (without auto-generated fields)
type CreateProduct = Omit<Product, "id" | "createdAt">;

// Type for updating a product (everything optional)
type UpdateProduct = Partial<Omit<Product, "id">>;

// Type for list display
type ProductListItem = Pick<Product, "id" | "name" | "price" | "inStock">;

// Map of categories to products
type ProductsByCategory = Record<string, Product[]>;

// Extract only string keys
type StringKeys = Extract<keyof Product, string>;

// Event types
type ProductEvent = "created" | "updated" | "deleted" | "archived";
type ActiveEvent = Exclude<ProductEvent, "archived">;
// "created" | "updated" | "deleted"

Branded Types#

Branded types allow you to create nominal types in TypeScript's structural type system. They prevent accidentally passing values with the same base type but different semantic meaning.

// Branded type definition
type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
type USD = Brand<number, "USD">;
type EUR = Brand<number, "EUR">;

// Smart constructors
function createUserId(id: string): UserId {
  if (!id.match(/^usr_[a-zA-Z0-9]+$/)) {
    throw new Error("Invalid user ID format");
  }
  return id as UserId;
}

function createEmail(email: string): Email {
  if (!email.includes("@")) {
    throw new Error("Invalid email format");
  }
  return email as Email;
}

function createUSD(amount: number): USD {
  return Math.round(amount * 100) / 100 as USD;
}

// Now TypeScript protects against mistakes
function getUser(id: UserId): Promise<User> {
  return fetch(`/api/users/${id}`).then(r => r.json());
}

function getOrder(id: OrderId): Promise<Order> {
  return fetch(`/api/orders/${id}`).then(r => r.json());
}

const userId = createUserId("usr_abc123");
const orderId = "ord_xyz789" as OrderId;

getUser(userId);   // OK
// getUser(orderId); // Błąd! OrderId nie jest UserId
// getUser("some-string"); // Błąd! string nie jest UserId

// Branded types prevent mixing currencies
function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD;
}

const price = createUSD(29.99);
const tax = createUSD(5.00);
addUSD(price, tax); // OK
// addUSD(price, 100 as EUR); // Błąd!

Discriminated Unions#

Discriminated unions are a pattern where a common field (discriminator) allows TypeScript to narrow the union type.

// Defining discriminated union
interface LoadingState {
  status: "loading";
}

interface SuccessState<T> {
  status: "success";
  data: T;
}

interface ErrorState {
  status: "error";
  error: {
    message: string;
    code: number;
  };
}

type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState;

// Exhaustive pattern matching
function renderState<T>(state: AsyncState<T>): string {
  switch (state.status) {
    case "loading":
      return "Loading...";
    case "success":
      return `Data: ${JSON.stringify(state.data)}`;
    case "error":
      return `Error ${state.error.code}: ${state.error.message}`;
    default:
      // Exhaustiveness check - should never be reached
      const _exhaustive: never = state;
      return _exhaustive;
  }
}

// More complex example - payment system
type PaymentMethod =
  | { type: "card"; cardNumber: string; expiryDate: string; cvv: string }
  | { type: "bankTransfer"; iban: string; bic: string }
  | { type: "paypal"; email: string }
  | { type: "crypto"; walletAddress: string; network: "ethereum" | "bitcoin" };

function processPayment(method: PaymentMethod): void {
  switch (method.type) {
    case "card":
      console.log(`Processing card ending in ${method.cardNumber.slice(-4)}`);
      break;
    case "bankTransfer":
      console.log(`Transferring to IBAN: ${method.iban}`);
      break;
    case "paypal":
      console.log(`PayPal payment to ${method.email}`);
      break;
    case "crypto":
      console.log(`Crypto payment on ${method.network}: ${method.walletAddress}`);
      break;
  }
}

// Generic Result type (inspired by Rust)
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}

function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) return err("Division by zero");
  return ok(a / b);
}

const result = divide(10, 2);
if (result.ok) {
  console.log(result.value); // typ: number
} else {
  console.log(result.error); // typ: string
}

Pattern Matching with Generics#

TypeScript does not have built-in pattern matching like Rust or Scala, but we can simulate it using generics and conditional types.

// Type-level pattern matching
type Match<T, Cases extends [any, any][]> =
  Cases extends [[infer Pattern, infer Result], ...infer Rest extends [any, any][]]
    ? T extends Pattern
      ? Result
      : Match<T, Rest>
    : never;

// Practical value-level matcher
type Matcher<T, R> = {
  [K in T extends { type: infer U extends string } ? U : never]: (
    value: Extract<T, { type: K }>
  ) => R;
};

function match<T extends { type: string }, R>(
  value: T,
  handlers: Matcher<T, R>
): R {
  const handler = (handlers as any)[value.type];
  return handler(value);
}

// Usage z discriminated union
type Shape =
  | { type: "circle"; radius: number }
  | { type: "rectangle"; width: number; height: number }
  | { type: "triangle"; base: number; height: number };

const area = match<Shape, number>(
  { type: "circle", radius: 5 },
  {
    circle: ({ radius }) => Math.PI * radius ** 2,
    rectangle: ({ width, height }) => width * height,
    triangle: ({ base, height }) => (base * height) / 2,
  }
);

Practical Design Patterns#

Repository Pattern#

The generic Repository pattern abstracts data operations, allowing you to write code independent of the data source.

interface Entity {
  id: string;
  createdAt: Date;
  updatedAt: Date;
}

interface Repository<T extends Entity> {
  findById(id: string): Promise<T | null>;
  findAll(filter?: Partial<T>): Promise<T[]>;
  create(data: Omit<T, "id" | "createdAt" | "updatedAt">): Promise<T>;
  update(id: string, data: Partial<Omit<T, "id" | "createdAt">>): Promise<T>;
  delete(id: string): Promise<boolean>;
  count(filter?: Partial<T>): Promise<number>;
}

interface User extends Entity {
  name: string;
  email: string;
  role: "admin" | "user";
}

interface Product extends Entity {
  name: string;
  price: number;
  category: string;
}

class InMemoryRepository<T extends Entity> implements Repository<T> {
  private items: Map<string, T> = new Map();

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

  async findAll(filter?: Partial<T>): Promise<T[]> {
    let results = Array.from(this.items.values());
    if (filter) {
      results = results.filter((item) =>
        Object.entries(filter).every(
          ([key, value]) => item[key as keyof T] === value
        )
      );
    }
    return results;
  }

  async create(data: Omit<T, "id" | "createdAt" | "updatedAt">): Promise<T> {
    const now = new Date();
    const entity = {
      ...data,
      id: crypto.randomUUID(),
      createdAt: now,
      updatedAt: now,
    } as T;
    this.items.set(entity.id, entity);
    return entity;
  }

  async update(
    id: string,
    data: Partial<Omit<T, "id" | "createdAt">>
  ): Promise<T> {
    const existing = this.items.get(id);
    if (!existing) throw new Error(`Entity ${id} not found`);
    const updated = { ...existing, ...data, updatedAt: new Date() };
    this.items.set(id, updated);
    return updated;
  }

  async delete(id: string): Promise<boolean> {
    return this.items.delete(id);
  }

  async count(filter?: Partial<T>): Promise<number> {
    const items = await this.findAll(filter);
    return items.length;
  }
}

// Usage
const userRepo: Repository<User> = new InMemoryRepository<User>();
const productRepo: Repository<Product> = new InMemoryRepository<Product>();

Builder Pattern#

The generic Builder allows building objects step by step with full type support.

class Builder<T extends Record<string, any>> {
  private data: Partial<T> = {};

  set<K extends keyof T>(key: K, value: T[K]): this {
    this.data[key] = value;
    return this;
  }

  build(): T {
    return this.data as T;
  }
}

// Type-safe builder with required fields
type RequiredKeys<T, K extends keyof T> = Required<Pick<T, K>> &
  Partial<Omit<T, K>>;

class TypeSafeBuilder<T extends Record<string, any>, Set extends keyof T = never> {
  private data: Partial<T> = {};

  set<K extends keyof T>(
    key: K,
    value: T[K]
  ): TypeSafeBuilder<T, Set | K> {
    this.data[key] = value;
    return this as any;
  }

  build(this: TypeSafeBuilder<T, keyof T>): T {
    return this.data as T;
  }
}

interface Config {
  host: string;
  port: number;
  database: string;
  ssl: boolean;
}

const config = new TypeSafeBuilder<Config>()
  .set("host", "localhost")
  .set("port", 5432)
  .set("database", "mydb")
  .set("ssl", true)
  .build(); // OK - wszystkie pola ustawione

Factory Pattern#

A generic factory enables creating instances of different types with a common interface.

// Generic Factory interface
interface Factory<T> {
  create(...args: any[]): T;
}

// Registry pattern with generics
class ServiceRegistry {
  private factories = new Map<string, Factory<any>>();

  register<T>(name: string, factory: Factory<T>): void {
    this.factories.set(name, factory);
  }

  resolve<T>(name: string): T {
    const factory = this.factories.get(name);
    if (!factory) {
      throw new Error(`Service "${name}" not registered`);
    }
    return factory.create();
  }
}

// Type-safe event emitter with generics
type EventMap = Record<string, any>;

class TypedEventEmitter<Events extends EventMap> {
  private handlers = new Map<keyof Events, Set<Function>>();

  on<K extends keyof Events>(
    event: K,
    handler: (payload: Events[K]) => void
  ): () => void {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, new Set());
    }
    this.handlers.get(event)!.add(handler);

    // Return unsubscribe function
    return () => {
      this.handlers.get(event)?.delete(handler);
    };
  }

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

// Usage
interface AppEvents {
  userLoggedIn: { userId: string; timestamp: Date };
  orderPlaced: { orderId: string; total: number };
  error: { message: string; code: number };
}

const emitter = new TypedEventEmitter<AppEvents>();

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

emitter.on("orderPlaced", ({ orderId, total }) => {
  console.log(`Order ${orderId} placed: $${total}`);
});

emitter.emit("userLoggedIn", {
  userId: "usr_123",
  timestamp: new Date(),
});

// emitter.emit("userLoggedIn", { wrong: "data" }); // Błąd!

Advanced Type Composition Techniques#

Combining multiple advanced techniques allows creating extremely expressive and safe APIs.

// Deep Partial - recursively optional fields
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

// Deep Readonly - recursively immutable
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

// Paths - generating paths to nested object fields
type Paths<T, Prefix extends string = ""> = {
  [K in keyof T & string]: T[K] extends object
    ? Paths<T[K], `${Prefix}${K}.`> | `${Prefix}${K}`
    : `${Prefix}${K}`;
}[keyof T & string];

interface NestedConfig {
  database: {
    host: string;
    port: number;
    credentials: {
      user: string;
      password: string;
    };
  };
  cache: {
    ttl: number;
    enabled: boolean;
  };
}

type ConfigPaths = Paths<NestedConfig>;
// "database" | "database.host" | "database.port" | "database.credentials" | ...

// Type-safe deep get
function get<T, P extends string>(
  obj: T,
  path: P
): P extends `${infer K}.${infer Rest}`
  ? K extends keyof T
    ? Rest extends Paths<T[K]>
      ? any
      : never
    : never
  : P extends keyof T
    ? T[P]
    : never {
  return path.split(".").reduce((acc: any, key) => acc?.[key], obj);
}

Summary#

Generics in TypeScript are the foundation for creating scalable, safe, and reusable code. The key techniques we covered include:

  • Generic functions, classes, and interfaces - the foundation of code reusability
  • Constraints (extends) - precise control over allowed types
  • Conditional types - logic at the type system level
  • Mapped types - transformation of existing types
  • Template literal types - operations on string types
  • Infer - extracting types from within other types
  • Utility types - ready-made tools for type manipulation
  • Branded types - nominal typing for greater safety
  • Discriminated unions - safe state modeling
  • Design patterns - Repository, Builder, Factory with generics

Mastering these techniques allows you to create type systems that reflect business logic and catch errors at compile time, before they reach production.


At MDS Software Solutions Group, TypeScript and advanced generics are part of our team's daily work. We create type-safe applications that are easier to maintain and develop. Need support with a TypeScript project? Contact us - we are happy to help!

Author
MDS Software Solutions Group

Team of programming experts specializing in modern web technologies.

TypeScript Generics - Advanced Types in Practice | MDS Software Solutions Group | MDS Software Solutions Group