TypeScript function return value with generic union restraint

Viewed 362

I am having trouble understanding how I can implement generics in TypesScript. I know there are many similar questions but none seem to address my use case. (There is this question, but I have a different concern, namely that the output of the function is the same type as the input of the function)

I would like to have a function like this, where the generic is limited to two different types, and the output is the same type as the input:

const increase = <T extends string | number>(
  param: T,
): T => {
    if (typeof param === "number") {
        // Here typescript should know that param can only be a number
        return param + 1;
    }
    // Here TypeScript should know that param can only be a string
    return "one more " + param;
};

const stringResult = increase("apple"); // "one more apple"
// Here TypeScript should know that stringResult is a string

const numResult = increase(2); // 3
// Here TypeScript should know that numResult is a number

However, this produces errors like the following:

Type 'number' is not assignable to type 'T'.
  'number' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'string | number'.

But I did the type checking in the if statement above, ensuring that T should be a number and not a string | number.

Is what I'm desiring possible in TypeScript? And if so, what am I doing wrong?

2 Answers

If you don't need the function to be an arrow function you can easily overload the function:

function increase(param: string): string;

function increase(param: number): number;

function increase(param: string | number): string | number {
    if (typeof param === "number") {
        // Here typescript should know that param can only be a number
        return param + 1;
    }
    // Here TypeScript should know that param can only be a string
    return "one more " + param;
}

const stringResult = increase("apple"); // "one more apple"
// Here TypeScript should know that stringResult is a string

const numResult = increase(2); // 3
// Here TypeScript should know that numResult is a number

Playground Link

This will also allow you to provide individual Intellisense detailing the difference in behaviour depending on the parameter type.

If you absolutely need the function to be an arrow function, see this answer on how to overload an arrow function

Because JavaScript doesn't have types it cannot provide true function overloading. To solve this, TypeScript allows you to provide additional function signatures to express overloading. The actual implementation of the function must then manually differentiate between parameter types and choose the appropriate behaviour. See also the TypeScript Handbook.

Yeah, as you've discovered, the TypeScript compiler isn't sophisticated enough to follow each path through the program and check types in this way. Indexed types can be a solution to your problem, but these aren't yet flexible enough for your use case.

There is a way to do this rigorously but—hold onto your hats—it might be a bit Java-esque for some tastes: the visitor pattern.

Essentially, if you wrap each of the possible input types in a class so that they all implement a shared interface, you can write visitor objects to carry out whatever functionality you need, type by type, and the compiler will ensure the visitor objects have fully typed functionality for all classes implementing that shared interface. And no if or switch in sight, so there can be no complaints from Uncle Bob.

This could look like this:

interface Increase<T> {
    value: T
    accept(visitor: IncreaseVisitor): T
}

class NumIncrease implements Increase<number>{
    constructor(public value: number) {}
    accept(visitor: IncreaseVisitor): number {
        return visitor.visitNum(this)
    }
}

class StrIncrease implements Increase<string>{
    constructor(public value: string) {}
    accept(visitor: IncreaseVisitor): string {
        return visitor.visitStr(this)
    }
}

interface IncreaseVisitor {
    visitNum(numIncrease: Increase<number>): number
    visitStr(strIncrease: Increase<string>): string
}

const oneMoreVisitor: IncreaseVisitor = {
    visitNum: (numIncrease: Increase<number>) => numIncrease.value + 1,
    visitStr: (strIncrease: Increase<string>) => "one more " + strIncrease.value
}

function increase <T>(param: Increase<T>): T {
    return param.accept( oneMoreVisitor )
}

const stringResult = increase(new StrIncrease("apple"))
console.log(stringResult) // "one more apple"
// Now TypeScript *does* know that stringResult is a string

const numResult = increase(new NumIncrease(2))
console.log(numResult) // 3
// Now TypeScript *does* know that numResult is a number
Related