
TypeScript: Function Composition and Recurrent Types
Advanced TypeScript concepts exploring function composition and recursive types for robust, maintainable code.
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:
- Keep compositions simple and functions pure when possible
- Limit recursion depth in types to prevent compilation issues
- Use memoization for performance-critical recursive operations
- 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.