Home > other >  Typescript enforce function return type to be key of interface based on parameter
Typescript enforce function return type to be key of interface based on parameter

Time:04-05

Basically what I am trying to achieve is to enforce function to return a correct type based on the key passed to the function. So if the key is service1 the correct return type should be Payloads['service1']. How can I achieve this?

interface Payloads {
    service1: {
        a: boolean;
    };
    service2: {
        b: string;
    };
    service3: {
        c: number;
    };
};

const createPayload = <S extends keyof Payloads>(key: S): Payloads[S] => {
    switch (key) {
        case 'service1': return { a: true }
        case 'service2': return { b: 'e' }
        case 'service3': return { c: 3 }
        default: throw new Error('undefined service')
    }
}

Error I get:

error

TypeScript playground link

CodePudding user response:

TypeScript doesn't know the type of S in the body of the function, so it expects you to pass all properties to make sure Payloads[S] is fulfilled. But you can trick TypeScript! By changing the return type to Payloads[keyof Payloads] it means one of the options, and you don't get any error.

Now this has changed the public method signature, but we don't want that. To make this work we have to use function declarations, because they allow overloads. We are going to add one overload to the function, which is the old signature:

function createPayload<S extends keyof Payloads>(key: S): Payloads[S];
function createPayload<S extends keyof Payloads>(key: S): Payloads[keyof Payloads] {
    // code here...
}

This exposes Payloads[S] to the caller, but internally we expect Payloads[keyof Payloads]. A full working example here.

CodePudding user response:

The type Payloads[S] is an indexed access type that depends on an as-yet unspecified generic type parameter S.

In your implementation, the compiler is unable to use control flow analysis to narrow the type parameter S in the switch/case statement. It sees that key can be narrowed to, for example, "service1", but it does not narrow the type parameter S. And therefore it doesn't know that { a: true } will be assignable to Payloads[S] in that circumstance. The compiler is essentially being too cautious here; the only thing it would be happy returning is a value which is assignable to Payloads[S] no matter what S is, which turns out to be the intersection of all the value types, equivalent to {a: boolean; b: string; c: number}. Since you never return a value like this, the compiler complains.

There are several open issues in GitHub asking for some improvement here. See microsoft/TypeScript#33014 for example. For now, though (as of TS4.6), if you must write code that works this way, then the compiler won't help you verify type safety. You will need to take over the responsibility by using something like type assertions

const createPayloadAssert = <S extends keyof Payloads>(key: S): Payloads[S] => {
    switch (key) {
        case 'service1': return { a: true } as Payloads[S]
        case 'service2': return { b: 'e' } as Payloads[S]
        case 'service3': return { c: 3 } as Payloads[S]
        default: throw new Error('undefined service')
    }
}

or a single-call-signature overload

function createPayloadOverload<S extends keyof Payloads>(key: S): Payloads[S];
function createPayloadOverload(key: keyof Payloads) {
    switch (key) {
        case 'service1': return { a: true };
        case 'service2': return { b: 'e' };
        case 'service3': return { c: 3 };
        default: throw new Error('undefined service')
    }
}

to loosen things enough to prevent the error. This necessarily allows you to make mistakes where you accidentally switch around the return values. But for now that's the best you can do with switch/case.


If you are willing to refactor your implementation to a form where the compiler can actually verify the safety of your code, you can do it by indexing into an object:

const createPayload = <S extends keyof Payloads>(key: S): Payloads[S] => ({
    service1: { a: true },
    service2: { b: 'e' },
    service3: { c: 3 }
}[key]);

const createPayloadBad = <S extends keyof Payloads>(key: S): Payloads[S] => ({
    service1: { a: true },
    service2: { a: true }, // <-- error!
    service3: { c: 3 }
}[key]);

This works because, among other things, indexed access types were introduced to TypeScript (called "lookup types" in that link) in order to represent at the type level what happens when you index into an object with a key at the value level. That is, if you have an object-like value t of type T, and a key-like value k of type K, then when indexing into t with k like t[k], the property you read will be of type T[K]. So if you want the compiler to know that you have a value of type Payloads[S], you can do so by indexing into a value of type Payloads with a key of type S, as shown above.

Playground link to code

  • Related