Search code examples
typescript

Flexible return value type using generics in TypeScript


I want to return different objects depending on the provided parameter, but get error ts(2322). How can I achieve this: returning different types based on the parameter passed to the function?

Here is a simplified example of the code I need: if fruit equals one value, I expect one format of the returned object, otherwise something different.

type Fruit = 'banana' | 'apple'
type FruitResult<T extends Fruit> = T extends 'apple' ? { a: string } : { b: string }

function getResult<T extends Fruit>({ fruit, ID }: { ID: string; fruit: T }): FruitResult<T> {
    if (fruit === 'apple') return { a: appleWork({ ID }) }
    return { b: bananaWork({ ID }) }
}

const fruit: Fruit = 'apple',
    result = getResult<typeof fruit>({ ID: '', fruit }),
    resultWithoutType = getResult({ ID: '', fruit })

function appleWork({ ID }: { ID: string }) {
    return ''
}
function bananaWork({ ID }: { ID: string }) {
    return ''
}

Error that I get: Type '{ a: number; }' is not assignable to type 'FruitResult<T>'.ts(2322)

I saw Type return value of function with generics in TypeScript and I don't think it's what I'm looking for.


Solution

  • Currently TypeScript can't use control flow analysis to re-constrain a generic type parameter inside a generic function. So checking (fruit === 'apple') will do something to the apparent type of fruit (e.g., narrowing from T to T & 'apple'), it won't do anything to T itself. This issue is the subject of microsoft/TypeScript#33014. That implies, among other things, that you can't safely implement a function that returns a generic conditional type like FruitResult<T>. This issue is the subject of microsoft/TypeScript#33912. It's possible that these missing features will be implemented in some upcoming version of TypeScript (there's a pull request at microsoft/TypeScript#56941 that might do it) but as of TypeScript 5.4 it's not part of the language.

    Until that happens, you could either just give up on compiler verified type safety and use type assertions or some other type loosening:

    function getResult<T extends Fruit>(
        { fruit, ID }: { ID: string; fruit: T }
    ): FruitResult<T> {
        if (fruit === 'apple')
            return { a: appleWork({ ID }) } as FruitResult<T> // 🤷‍♂️
        return { b: bananaWork({ ID }) } as FruitResult<T> // 🤷‍♂️
    }
    

    Or, you could refactor away from control flow analysis to use generic indexing instead. TypeScript does know how to represent "look up a property in an object whose key is a generic type", and you can rewrite the if statement above to be in the form of such an object lookup. It's weird, but it works:

    interface FruitMap {
        apple: { a: string };
        banana: { b: string };
    }
    type Fruit = keyof FruitMap;
    
    function getResult<K extends Fruit>(
        { fruit, ID }: { ID: string; fruit: K }
    ): FruitMap[K] {
        return {
            get apple() { return { a: appleWork({ ID }) } },
            get banana() { return { b: bananaWork({ ID }) } }
        }[fruit];
    }
    

    Here we've defined FruitMap where FruitMap[K] is equivalent to FruitResult<K>. But now FruitMap is just some object type. Inside the getResult() function, we index into an object of type FruitMap with a key of type K and get FruitMap[K].

    And that object of type FruitMap is implemented with getters for its properties, so that it really only ever evaluates the same code path as your version. If you call getResult({fruit: "apple", ID: ""}), then only the get apple() implementation will be called, so appleWork() will be evaluated and bananaWork() will not.

    And luckily that all type checks. It's not really idiomatic JavaScript, but it works, and will catch errors if you accidentally return the wrong return value. With type assertions, changing (fruit === 'apple') to (fruit !== 'apple') won't be caught, whereas the indexing equivalent (where you swap the getter implementations) will be.

    Playground link to code