TypeScript: Function Composition and Recurrent Types
TypeScriptFunctional ProgrammingAdvanced Types

TypeScript: Function Composition and Recurrent Types

Advanced TypeScript concepts exploring function composition and recursive types for robust, maintainable code.

January 11, 2019
8 min read

Originally published on Ackee Blog on January 11, 2019

In this technical deep dive, I explore advanced TypeScript concepts, focusing on function composition and recurrent types. Through code examples and explanations, I’ll demonstrate how these features can be leveraged to create more robust and maintainable codebases with strong type safety.

Introduction to Advanced TypeScript Patterns

TypeScript’s type system is incredibly powerful, enabling developers to express complex relationships and constraints at compile time. Two particularly interesting areas are function composition—building complex functions from simpler ones—and recurrent types—types that reference themselves to model recursive data structures. For more advanced TypeScript patterns, check out the TypeScript GitHub issues and DefinitelyTyped for community type definitions.

Function Composition in TypeScript

Function composition is a fundamental concept in functional programming where you combine simple functions to build more complex ones. TypeScript’s type system can help ensure these compositions are type-safe.

Basic Function Composition

Let’s start with a simple composition utility:

// Basic composition function
function compose<A, B, C>(
  f: (b: B) => C,
  g: (a: A) => B
): (a: A) => C {
  return (a: A) => f(g(a));
}

// Example usage
const addOne = (x: number): number => x + 1;
const multiplyByTwo = (x: number): number => x * 2;

const addOneThenMultiplyByTwo = compose(multiplyByTwo, addOne);
// Type: (a: number) => number

console.log(addOneThenMultiplyByTwo(5)); // 12

Advanced Multi-Function Composition

For more complex scenarios, we can create a pipe function that composes multiple functions:

// Pipe function for multiple compositions
type Func<T, R> = (arg: T) => R;

function pipe<T1, T2>(f1: Func<T1, T2>): Func<T1, T2>;
function pipe<T1, T2, T3>(
  f1: Func<T1, T2>,
  f2: Func<T2, T3>
): Func<T1, T3>;
function pipe<T1, T2, T3, T4>(
  f1: Func<T1, T2>,
  f2: Func<T2, T3>,
  f3: Func<T3, T4>
): Func<T1, T4>;
function pipe<T1, T2, T3, T4, T5>(
  f1: Func<T1, T2>,
  f2: Func<T2, T3>,
  f3: Func<T3, T4>,
  f4: Func<T4, T5>
): Func<T1, T5>;

function pipe(...fns: Func<any, any>[]): Func<any, any> {
  return (arg: any) => fns.reduce((acc, fn) => fn(acc), arg);
}

// Usage example
const processNumber = pipe(
  (x: number) => x.toString(),          // number -> string
  (s: string) => s.padStart(3, '0'),    // string -> string
  (s: string) => `ID: ${s}`,            // string -> string
  (s: string) => s.toUpperCase()        // string -> string
);

console.log(processNumber(42)); // "ID: 042"

Recurrent (Recursive) Types

Recursive types allow us to model data structures that reference themselves, such as trees, linked lists, or nested object structures.

Basic Recursive Types

// Simple linked list
interface ListNode<T> {
  value: T;
  next: ListNode<T> | null;
}

// Tree structure
interface TreeNode<T> {
  value: T;
  children: TreeNode<T>[];
}

// Nested object with arbitrary depth
interface NestedObject {
  [key: string]: string | number | NestedObject;
}

Advanced Recursive Type Patterns

JSON-like Structure with Strong Typing

type JSONValue = 
  | string
  | number
  | boolean
  | null
  | JSONObject
  | JSONArray;

interface JSONObject {
  [key: string]: JSONValue;
}

interface JSONArray extends Array<JSONValue> {}

// This allows us to type JSON data with full type safety
const data: JSONValue = {
  name: "John",
  age: 30,
  address: {
    street: "123 Main St",
    coordinates: [40.7128, -74.0060]
  },
  hobbies: ["reading", "coding"]
};

File System Structure

interface FileSystemNode {
  name: string;
  type: 'file' | 'directory';
  size?: number; // Only for files
  children?: FileSystemNode[]; // Only for directories
}

// Helper function to create a file
function createFile(name: string, size: number): FileSystemNode {
  return { name, type: 'file', size };
}

// Helper function to create a directory
function createDirectory(name: string, children: FileSystemNode[] = []): FileSystemNode {
  return { name, type: 'directory', children };
}

// Build a file system structure
const fileSystem: FileSystemNode = createDirectory('root', [
  createDirectory('src', [
    createFile('index.ts', 1024),
    createFile('utils.ts', 512),
    createDirectory('components', [
      createFile('Button.tsx', 2048),
      createFile('Modal.tsx', 1536)
    ])
  ]),
  createFile('package.json', 256),
  createFile('README.md', 128)
]);

Recursive Type Utilities

Deep Readonly

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

interface MutableConfig {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  features: string[];
}

type ReadonlyConfig = DeepReadonly<MutableConfig>;
// All properties and nested properties are readonly

Deep Partial

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

function updateConfig(
  current: MutableConfig,
  updates: DeepPartial<MutableConfig>
): MutableConfig {
  // Implementation would merge the updates into current config
  return { ...current, ...updates } as MutableConfig;
}

// Usage
const configUpdate: DeepPartial<MutableConfig> = {
  database: {
    port: 5432 // Only updating port, other database fields optional
  }
};

Combining Function Composition and Recursive Types

Let’s create a powerful example that combines both concepts:

Recursive Data Transformation

// Define a recursive data structure for forms
interface FormField {
  type: 'text' | 'number' | 'group';
  name: string;
  value?: string | number;
  children?: FormField[];
  validation?: (value: any) => boolean;
}

// Recursive transformation functions
type FieldTransformer<T> = (field: FormField) => T;

function mapFormFields<T>(
  field: FormField,
  transformer: FieldTransformer<T>
): T | T[] {
  if (field.type === 'group' && field.children) {
    return field.children.map(child => 
      mapFormFields(child, transformer)
    ).flat();
  }
  return transformer(field);
}

// Composition functions for field processing
const validateField = (field: FormField): { name: string; isValid: boolean } => ({
  name: field.name,
  isValid: field.validation ? field.validation(field.value) : true
});

const extractValue = (field: FormField): { name: string; value: any } => ({
  name: field.name,
  value: field.value
});

// Process entire form with composition
const processForm = (form: FormField) => pipe(
  (f: FormField) => mapFormFields(f, validateField),
  (validations: any[]) => validations.flat(),
  (validations: { name: string; isValid: boolean }[]) => ({
    isFormValid: validations.every(v => v.isValid),
    fieldValidations: validations
  })
)(form);

Best Practices and Performance Considerations

Type-Level Optimizations

// Use conditional types to optimize recursive operations
type OptimizedDeepReadonly<T> = T extends (infer U)[]
  ? ReadonlyArray<OptimizedDeepReadonly<U>>
  : T extends object
    ? { readonly [K in keyof T]: OptimizedDeepReadonly<T[K]> }
    : T;

// Limit recursion depth to prevent infinite types
type DeepReadonlyLimited<T, Depth extends number = 5> = Depth extends 0
  ? T
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonlyLimited<T[K], Prev<Depth>> }
    : T;

type Prev<T extends number> = [-1, 0, 1, 2, 3, 4, 5][T];

Runtime Performance

// Memoization for recursive functions
function memoize<Args extends any[], Return>(
  fn: (...args: Args) => Return
): (...args: Args) => Return {
  const cache = new Map();
  
  return (...args: Args): Return => {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      return cache.get(key);
    }
    
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

// Memoized recursive function
const memoizedTreeSearch = memoize(<T>(
  node: TreeNode<T>,
  predicate: (value: T) => boolean
): TreeNode<T> | null => {
  if (predicate(node.value)) {
    return node;
  }
  
  for (const child of node.children) {
    const result = memoizedTreeSearch(child, predicate);
    if (result) return result;
  }
  
  return null;
});

Conclusion

Function composition and recursive types are powerful features in TypeScript that enable developers to build robust, type-safe applications. Key takeaways:

Function Composition Benefits:

  • Modularity: Build complex logic from simple, testable functions
  • Type Safety: Ensure correct data flow between composed functions
  • Reusability: Create flexible, reusable processing pipelines

Recursive Types Benefits:

  • Accurate Modeling: Represent complex, nested data structures accurately
  • Type Safety: Maintain type safety across arbitrary nesting levels
  • Flexibility: Handle dynamic structures while preserving compile-time checks

Best Practices:

  1. Keep compositions simple and functions pure when possible
  2. Limit recursion depth in types to prevent compilation issues
  3. Use memoization for performance-critical recursive operations
  4. Test thoroughly as complexity increases with composition and recursion

These advanced TypeScript patterns unlock new possibilities for creating maintainable, type-safe codebases that can handle complex data transformations and structures while providing excellent developer experience and compile-time guarantees.

By mastering these concepts, developers can leverage TypeScript’s full potential to build more robust applications with better error detection, improved refactoring safety, and enhanced code documentation through types.