🚀 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.

How to store functions with same parameter but different generic in an object?

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` and `Processor` are two completely distinct and incompatible types from the compiler’s perspective. There is no single, simple type that can represent “an object containing various kinds of `Processor`” while preserving full type safety for each member. The compiler needs to know the exact type of `T` at compile time, and it can’t be both `string` and `number` for the same base type.

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.

Darian Vance - Lead Cloud Architect

Darian Vance

Lead Cloud Architect & DevOps Strategist

With over 12 years in system architecture and automation, Darian specializes in simplifying complex cloud infrastructures. An advocate for open-source solutions, he founded TechResolve to provide engineers with actionable, battle-tested troubleshooting guides and robust software alternatives.


🤖 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` and `Processor` as distinct and incompatible types. There is no single type that can represent an object containing various kinds of `Processor` while preserving full compile-time type safety for each member, as the compiler needs to know the exact type of `T`.

âť“ 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

Discover more from TechResolve - SaaS Troubleshooting & Software Alternatives

Subscribe now to keep reading and get access to the full archive.

Continue reading