🚀 Executive Summary
TL;DR: The article addresses the challenge of storing TypeScript functions with identical parameter signatures but differing generic types within a single object, which TypeScript’s structural typing prevents due to type incompatibility. It offers robust solutions like the Wrapper Pattern and Discriminated Unions to maintain type safety and code clarity, avoiding the pitfalls of using ‘any’.
🎯 Key Takeaways
- TypeScript’s structural typing considers `Processor
` and `Processor ` as distinct and incompatible types, preventing direct storage in a single object while preserving full compile-time type safety. - Using `any` to bypass generic type issues provides a quick fix but completely sacrifices type safety, leading to potential runtime errors like calling `.toUpperCase()` on a number.
- The Wrapper Pattern encapsulates generic functions within a common, non-generic interface (`IProcessorWrapper`), using runtime type guards to safely execute the specific generic function.
- Discriminated Unions offer maximum type safety by tagging each processor type (e.g., `kind: ‘string’`), enabling safe retrieval and execution through type narrowing (e.g., `switch` statements).
- The choice of solution depends on context: Wrapper Pattern for general application-level code and plugin systems, Discriminated Unions for scenarios requiring type-specific reactions, and `any` only for emergency hotfixes.
A guide to managing collections of TypeScript functions with differing generic types, exploring type erasure, wrapper patterns, and discriminated unions to maintain type safety and code clarity.
Wrangling Generic Functions in TypeScript: A Guide from the Trenches
I remember a late night, staring at a failing deployment pipeline for a new microservice, `auth-service-v2`. The logs were screaming about a type mismatch. The problem? We had a central registry for “health check” functions. Each service would register its own function, which we’d call to see if it was alive. The legacy services registered a function that took a simple `string` (the server name, like `prod-db-01`). Our new service’s health check needed a `number` (a timeout in milliseconds). The function signatures were identical otherwise, but the TypeScript compiler, in its infinite and unforgiving wisdom, threw a fit. We were trying to put two tools with different-sized handles into the same perfectly molded toolbox slot. It just wouldn’t fit.
This is a classic TypeScript headache. You have a set of functions that do conceptually the same thing, but they operate on different data types. You want to store them in a single object or map for easy access, but the type system fights you every step of the way. Let’s break down why this happens and how to fix it without pulling all your hair out.
The “Why”: TypeScript’s Structural Typing and Generics
Before we dive into solutions, let’s understand the root of the problem. TypeScript uses a structural type system, meaning it cares about the *shape* of an object, not its explicit name. However, when generics get involved, things change. Let’s look at the code that inspired this post:
type Processor<T> = (payload: T) => void;
// The goal is to store different processors in one object
const processors = {
stringProcessor: (payload: string) => { console.log(`String: ${payload.toUpperCase()}`); },
numberProcessor: (payload: number) => { console.log(`Number: ${payload.toFixed(2)}`); }
};
// What type do we give 'processors'?
The core issue is that `Processor
So, how do we solve this? We have a few options, ranging from “get it done now” to “build it to last”.
Solution 1: The Quick Fix (The “Any” Hammer)
Look, it’s 2 AM, the on-call pager is buzzing, and you just need the pipeline to go green. I get it. In these moments, you can tell the TypeScript compiler to just look the other way. We can use `any` or a very broad function type to bypass the checks.
type Processor<T> = (payload: T) => void;
const stringProcessor: Processor<string> = (payload) => { /* ... */ };
const numberProcessor: Processor<number> = (payload) => { /* ... */ };
// Tell TypeScript to ignore the generic differences
const processors: { [key: string]: (payload: any) => void } = {
stringProcessor,
numberProcessor
};
// Now you can call them, but with a catch...
processors.stringProcessor('hello'); // Works
processors.numberProcessor(123.45); // Works
// ...but you've lost all type safety. This compiles with no errors!
processors.stringProcessor(999); // CRASH! .toUpperCase() is not a function on a number
Darian’s Warning: Using `any` is like disabling the smoke detector because you burned the toast. It solves the immediate, annoying problem but leaves you vulnerable to a real fire. Use this approach to fix a critical bug, but create a tech-debt ticket to fix it properly first thing in the morning.
Solution 2: The Permanent Fix (The Wrapper Pattern)
A much cleaner, safer, and more scalable approach is to “erase” the generic type by wrapping it in a class or object that conforms to a common, non-generic interface. We create a standardized “handle” for all our different tools.
The idea is to create a contract that says, “I don’t care what specific type you work with internally, but you must have an `execute` method that I can call.”
// The original generic function type
type Processor<T> = (payload: T) => void;
// 1. Define a common, non-generic interface for our wrappers
interface IProcessorWrapper {
execute(payload: unknown): void;
}
// 2. Create a class that implements the interface and "hides" the generic
class ProcessorWrapper<T> implements IProcessorWrapper {
private processor: Processor<T>;
// A type guard to check if the payload is valid for THIS processor
private typeGuard: (payload: unknown) => payload is T;
constructor(processor: Processor<T>, typeGuard: (payload: unknown) => payload is T) {
this.processor = processor;
this.typeGuard = typeGuard;
}
execute(payload: unknown): void {
if (this.typeGuard(payload)) {
// Because of the type guard, TypeScript now knows 'payload' is of type 'T'
this.processor(payload);
} else {
console.error(`Invalid payload type for processor.`);
}
}
}
// 3. Now, we can store the wrappers in a clean, strongly-typed object
const processors: Record<string, IProcessorWrapper> = {
stringProcessor: new ProcessorWrapper(
(payload: string) => console.log(payload.toUpperCase()),
(p): p is string => typeof p === 'string'
),
numberProcessor: new ProcessorWrapper(
(payload: number) => console.log(payload.toFixed(2)),
(p): p is number => typeof p === 'number'
)
};
// Usage is safe and predictable
processors.stringProcessor.execute("hello world"); // HELLO WORLD
processors.numberProcessor.execute(123.456); // 123.46
processors.stringProcessor.execute(999); // Logs: Invalid payload type... (No runtime crash!)
This is my preferred method. It’s robust, scalable, and maintains type safety where it matters most—at the point of execution. You add a little boilerplate, but you gain a massive amount of stability and predictability.
Solution 3: The ‘Nuclear’ Option (Discriminated Unions)
Sometimes, you not only need to store the functions but also need to know *what kind* of function you’re dealing with after you retrieve it. In this case, a discriminated union is your most powerful weapon. It’s more verbose but provides maximum type safety both at storage and at retrieval.
Here, we create a “tagged” or “discriminated” object for each processor type.
type Processor<T> = (payload: T) => void;
// 1. Define the "shapes" for each processor type
type StringProcessorShape = {
kind: 'string';
process: Processor<string>;
}
type NumberProcessorShape = {
kind: 'number';
process: Processor<number>;
}
// 2. Create the union type
type AnyProcessor = StringProcessorShape | NumberProcessorShape;
// 3. Store them in an array or map
const processorList: AnyProcessor[] = [
{ kind: 'string', process: (p) => console.log(p.toUpperCase()) },
{ kind: 'number', process: (p) => console.log(p.toFixed(2)) }
];
// 4. Use a type guard or a switch statement to safely use them
function runProcessor(proc: AnyProcessor, data: unknown) {
switch (proc.kind) {
case 'string':
if (typeof data === 'string') {
// TypeScript now knows proc.process is (payload: string) => void
proc.process(data);
}
break;
case 'number':
if (typeof data === 'number') {
// And here, it knows it's (payload: number) => void
proc.process(data);
}
break;
}
}
// Usage
runProcessor(processorList[0], 'hello from discriminated union'); // HELLO FROM DISCRIMINATED UNION
runProcessor(processorList[1], 98.6); // 98.60
runProcessor(processorList[0], 123); // Does nothing, fails the 'if' check gracefully
This pattern is fantastic for things like event handlers or state machine actions, where the “kind” of action dictates the shape of the payload.
Summary: Choosing Your Weapon
Ultimately, the right solution depends on your context. To make it simple, here’s how I decide:
| Solution | When to Use It | Pros | Cons |
|---|---|---|---|
| 1. The “Any” Hammer | Emergency hotfix, proof-of-concept, or a non-critical internal script. | Fastest to implement. | Destroys type safety; risky. |
| 2. The Wrapper Pattern | The default choice for most application-level code. Service registries, plugin systems. | Excellent balance of safety and scalability. Hides complexity. | Requires some boilerplate code (wrapper class/interface). |
| 3. Discriminated Unions | When you need to react differently based on the processor’s type. Redux-style state management, event busses. | Maximum, provable type safety from end to end. | Most verbose; can be overkill for simple maps. |
Don’t let TypeScript’s strictness frustrate you. It’s not trying to be difficult; it’s trying to save you from those late-night production fires. By understanding *why* it’s complaining, you can choose the right pattern to work with the type system, not against it.
🤖 Frequently Asked Questions
âť“ Why can’t I directly store generic functions with different ‘T’ types in a single TypeScript object?
TypeScript’s structural type system considers `Processor
âť“ How do the Wrapper Pattern and Discriminated Unions compare for managing generic functions?
The Wrapper Pattern offers a balanced approach, hiding generic complexity behind a common `IProcessorWrapper` interface with runtime type guards, suitable for service registries. Discriminated Unions provide maximum, provable type safety by tagging each function type, ideal for scenarios like event handlers or state machine actions where the ‘kind’ dictates payload shape and requires type-specific reactions.
âť“ What is a common implementation pitfall when trying to store generic functions and how can it be avoided?
A common pitfall is using the ‘Any’ Hammer (`any`) to force generic functions into a single object, which destroys type safety and leads to runtime crashes. This can be avoided by implementing the Wrapper Pattern with explicit type guards, ensuring payloads are validated at runtime before execution, thus preventing invalid operations.
Leave a Reply