Zum Inhalt springen

Funktionale Programmiermuster in TypeScript

Pragmatische funktionale Programmiermuster für TypeScript, die die Code-Klarheit verbessern, ohne einen vollständigen Paradigmenwechsel zu erfordern.

5 Min. Lesezeit
TypeScript-Code mit compose- und pipe-Funktionen samt Typannotationen

Funktionale Programmierung in TypeScript bedeutet nicht, Klassen aufzugeben, alles mit Monaden neu zu schreiben oder eine puristische Philosophie zu übernehmen. Sie bedeutet, gezielt Muster anzuwenden — Unveränderlichkeit, reine Funktionen, Komposition — wo sie Komplexität reduzieren und Testbarkeit verbessern. Diese Muster koexistieren natürlich mit objektorientiertem Code. Das Ziel ist Pragmatismus, nicht Ideologie.

Die Muster, die im alltäglichen TypeScript den größten Wert liefern, sind einfacher, als du denkst.

Reine Funktionen

Eine reine Funktion liefert bei gleicher Eingabe die gleiche Ausgabe und hat keine Seiteneffekte. Diese eine Eigenschaft macht eine Funktion trivial testbar, sicher parallelisierbar und leicht nachvollziehbar.

tstypescript
// ❌ Impure — depends on external state, mutates input
let taxRate = 0.08;
function calculateTotal(items: CartItem[]) {
  let total = 0;
  items.forEach(item => {
    item.totalPrice = item.price * item.quantity; // mutation
    total += item.totalPrice;
  });
  return total * (1 + taxRate); // depends on external variable
}
 
// ✅ Pure — all inputs explicit, no mutation, no side effects
function calculateTotal(items: readonly CartItem[], taxRate: number): number {
  const subtotal = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );
  return subtotal * (1 + taxRate);
}

Nicht jede Funktion kann rein sein — HTTP-Requests, Datenbankaufrufe und Logging sind inhärent unrein. Die Strategie ist, unreine Operationen an die Ränder des Systems zu schieben und die Kernlogik rein zu halten.

Unveränderlichkeit als Standard

Mutation ist die Ursache einer ganzen Klasse von Bugs: veraltete Closures, korrupte Shared State, React-Rendering-Probleme, Race Conditions. TypeScript bietet readonly, um Mutation auf Typebene zu verhindern.

tstypescript
// ❌ Mutation — callers don't expect their data to change
function addDiscount(order: Order): Order {
  order.total *= 0.9;  // mutates the original
  order.discountApplied = true;
  return order;
}
 
// ✅ Immutable — returns a new object
function addDiscount(order: Readonly<Order>): Order {
  return {
    ...order,
    total: order.total * 0.9,
    discountApplied: true,
  };
}

Verwende readonly in Funktionsparametern, um Unveränderlichkeit durchzusetzen:

tstypescript
// ReadonlyArray prevents push, pop, splice
function getTopScores(scores: readonly number[], limit: number): number[] {
  // scores.sort() would error — readonly
  return [...scores].sort((a, b) => b - a).slice(0, limit);
}
 
// Readonly<T> makes all properties readonly
interface Config {
  host: string;
  port: number;
  features: string[];
}
 
function createServer(config: Readonly<Config>): Server {
  // config.port = 8080; // Error: readonly
  return new Server({ ...config });
}

Funktionskomposition

Komposition baut komplexe Operationen aus einfachen, wiederverwendbaren Funktionen. Statt einer einzigen Funktion, die fünf Dinge tut, komponierst du fünf Funktionen, die jeweils eine Sache tun.

tstypescript
// Pipe: left-to-right composition
function pipe<T>(...fns: Array<(arg: T) => T>): (arg: T) => T {
  return (arg: T) => fns.reduce((result, fn) => fn(result), arg);
}
 
// Individual transformation functions
const trim = (s: string) => s.trim();
const lowercase = (s: string) => s.toLowerCase();
const slugify = (s: string) => s.replace(/\s+/g, '-');
const removeSpecialChars = (s: string) => s.replace(/[^a-z0-9-]/g, '');
 
// Compose them into a pipeline
const toSlug = pipe(trim, lowercase, slugify, removeSpecialChars);
 
toSlug("  Hello World! 123  "); // "hello-world-123"

Für heterogene Typen unterstützt TypeScript variadische Generics in einem generischen pipe nicht gut. Verwende explizite Ketten oder Bibliotheken:

tstypescript
// Array processing pipeline
function processUsers(users: User[]): UserSummary[] {
  return users
    .filter(isActive)
    .filter(hasVerifiedEmail)
    .map(toUserSummary)
    .sort(byLastLogin);
}
 
// Each function is pure, testable independently
const isActive = (user: User): boolean => user.status === 'active';
const hasVerifiedEmail = (user: User): boolean => user.emailVerified;
const toUserSummary = (user: User): UserSummary => ({
  id: user.id,
  name: user.name,
  lastLogin: user.lastLoginAt,
});
const byLastLogin = (a: UserSummary, b: UserSummary): number =>
  b.lastLogin.getTime() - a.lastLogin.getTime();

Option/Result-Typen für Fehlerbehandlung

Anstatt bei erwarteten Fehlern Exceptions zu werfen, verwende Result-Typen, die Aufrufer zwingen, sowohl Erfolgs- als auch Fehlerfälle zu behandeln.

tstypescript
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 };
}
 
// Usage — caller must handle both cases
function parseConfig(raw: string): Result<Config, string> {
  try {
    const parsed = JSON.parse(raw);
    if (!parsed.host || !parsed.port) {
      return err('Missing required fields: host, port');
    }
    return ok({ host: parsed.host, port: parsed.port });
  } catch {
    return err('Invalid JSON');
  }
}
 
const result = parseConfig(configString);
if (result.ok) {
  startServer(result.value); // TypeScript knows value exists
} else {
  console.error(result.error); // TypeScript knows error exists
}
tstypescript
// ❌ Exception-based — nothing forces callers to handle errors
function findUser(id: string): User {
  const user = db.find(id);
  if (!user) throw new NotFoundError('User not found');
  return user;
}
// Caller might forget try/catch — runtime explosion
 
// ✅ Result-based — type system enforces error handling
function findUser(id: string): Result<User, 'not-found'> {
  const user = db.find(id);
  if (!user) return err('not-found');
  return ok(user);
}
// Caller can't access .value without checking .ok first

Funktionen höherer Ordnung

Funktionen, die Funktionen als Argumente erhalten oder Funktionen zurückgeben. Du verwendest sie bereits — map, filter, reduce sind Funktionen höherer Ordnung.

tstypescript
// Factory function — returns configured function
function createValidator<T>(rules: Array<(value: T) => string | null>) {
  return (value: T): string[] => {
    return rules
      .map(rule => rule(value))
      .filter((error): error is string => error !== null);
  };
}
 
const validatePassword = createValidator<string>([
  (pw) => pw.length < 8 ? 'Must be at least 8 characters' : null,
  (pw) => !/[A-Z]/.test(pw) ? 'Must contain uppercase letter' : null,
  (pw) => !/[0-9]/.test(pw) ? 'Must contain a number' : null,
]);
 
validatePassword('weak');       // ['Must be at least 8 characters', 'Must contain uppercase letter', 'Must contain a number']
validatePassword('StrongPass1'); // []

Memoization

tstypescript
function memoize<T extends (...args: any[]) => any>(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);
    cache.set(key, result);
    return result;
  }) as T;
}
 
// Expensive computation — memoize it
const computeLayout = memoize((width: number, items: number): Layout => {
  // ... complex layout calculation
  return { columns: Math.floor(width / 300), rows: Math.ceil(items / 3) };
});

Memoization funktioniert nur für reine Funktionen. Wenn die Funktion von externem Zustand abhängt, kann das gecachte Ergebnis veraltet sein.

Praktisches funktionales Refactoring

Diese Muster brillieren beim Refactoring komplexen prozeduralen Codes. Der Vorher/Nachher-Unterschied ist dramatisch.

tstypescript
// ❌ Procedural — hard to test, hard to modify
async function processOrders(orders: Order[]): Report {
  const report: Report = { total: 0, byStatus: {}, errors: [] };
 
  for (const order of orders) {
    try {
      if (!order.items?.length) {
        report.errors.push(`Order ${order.id}: no items`);
        continue;
      }
      const total = order.items.reduce((s, i) => s + i.price * i.qty, 0);
      const tax = total * 0.08;
      const status = total > 1000 ? 'high-value' : 'standard';
      report.total += total + tax;
      report.byStatus[status] = (report.byStatus[status] ?? 0) + 1;
    } catch (e) {
      report.errors.push(`Order ${order.id}: ${e}`);
    }
  }
  return report;
}
 
// ✅ Functional — each step is independently testable
const calculateOrderTotal = (order: Order): number =>
  order.items.reduce((sum, item) => sum + item.price * item.qty, 0);
 
const applyTax = (rate: number) => (amount: number): number =>
  amount * (1 + rate);
 
const classifyOrder = (total: number): string =>
  total > 1000 ? 'high-value' : 'standard';
 
const validateOrder = (order: Order): Result<Order, string> =>
  order.items?.length ? ok(order) : err(`Order ${order.id}: no items`);
 
function processOrders(orders: Order[]): Report {
  const results = orders.map(order => {
    const validation = validateOrder(order);
    if (!validation.ok) return { error: validation.error };
 
    const total = applyTax(0.08)(calculateOrderTotal(order));
    return { total, status: classifyOrder(total) };
  });
 
  return {
    total: results.reduce((s, r) => s + ('total' in r ? r.total : 0), 0),
    byStatus: results.reduce((acc, r) => {
      if ('status' in r) acc[r.status] = (acc[r.status] ?? 0) + 1;
      return acc;
    }, {} as Record<string, number>),
    errors: results.filter(r => 'error' in r).map(r => (r as any).error),
  };
}

Wichtige Erkenntnisse

  1. Reine Funktionen sind das Muster mit dem größten Hebel — keine Seiteneffekte bedeuten trivialen Test und sicheres Refactoring
  2. Standardmäßig Unveränderlichkeit — verwende readonly und Spread-Operatoren. Mutation ist eine Optimierung, keine Standardeinstellung.
  3. Komposition statt Komplexität — baue Pipelines aus kleinen Funktionen statt monolithischer Prozeduren
  4. Result-Typen ersetzen Exceptions bei erwarteten Fehlern — das Typsystem erzwingt Fehlerbehandlung
  5. Funktionen höherer Ordnung reduzieren Duplikation — Factories und Decorators erzeugen konfiguriertes Verhalten ohne Wiederholung
  6. Schrittweise anwenden — du musst nicht alles neu schreiben. Starte mit reinen Utility-Funktionen und komponiere von dort aus
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX