typescriptfunctiontypescript-typingsconditional-typestypescript-conditional-types

Typescript: Conditional function return type based on enum parameter value


looking for some help in typing a factory function that accepts a single enum as a paramter and returns a mapper function.

// enumeration of possible partners 
enum Partner {
    Google = 'google',
    Microsoft = 'microsoft'
}

// lets say it's a domain entity, we'll map partner DTOs to it
type Entity = {
    id: string
}

// DTO and mapper function for Partner.Google
type GoogleDto = {
    google_id: string
}

function mapFromGoogle(dto: GoogleDto): Entity {
    return {
        id: dto.google_id
    }
}

// DTO and mapper function for Partner.Microsoft
type MicrosoftDto = {
    ms_id: number
}

function mapFromMicrosoft(dto: MicrosoftDto): Entity {
    return {
        id: String(dto.ms_id)
    }
}

// And here I am trying to use a conditional type
// to check which enum value is passed as an argument
// and to provide a correct return type

function toEntity<T extends Partner>(partner: T): T extends Partner.Google ? typeof mapFromGoogle : typeof mapFromMicrosoft {
    switch(partner) {
        case Partner.Google:
            return mapFromGoogle // Type '(dto: GoogleDto) => Entity' is not assignable to type 'T extends Partner.Google ? (dto: GoogleDto) => Entity : (dto: MicrosoftDto) => Entity'
        case Partner.Microsoft:
            return mapFromMicrosoft // Type '(dto: MicrosoftDto) => Entity' is not assignable to type 'T extends Partner.Google ? (dto: GoogleDto) => Entity : (dto: MicrosoftDto) => Entity'
        default:
        throw new Error('Unsupported partner')
    }
}

const e1 = toEntity(Partner.Google)({ google_id: 'id' }) 
const e2 = toEntity(Partner.Google)({ google_id: 'id', ms_id: 4 }) // 'ms_id' does not exist in type 'GoogleDto'
const e3 = toEntity(Partner.Microsoft)({ ms_id: 10 })
const e4 = toEntity(Partner.Microsoft)({ ms_id: 10, google_id: 'asd' }) // 'google_id' does not exist in type 'MicrosoftDto'
const e5 = toEntity(Partner.Google)({}) // Property 'google_id' is missing in type '{}' but required in type 'GoogleDto'
const e6 = toEntity(Partner.Microsoft)({}) // Property 'ms_id' is missing in type '{}' but required in type 'MicrosoftDto'

The resulting function works as expected, it correctly relies on a provided partner and errors if invalid DTO id provided.

But TS shows error when I'm trying to return a specific mapper function from the toEntity function inside the case block.

Asking if someone can point me to the right direction in order to solve this case.

I've tried converting enumeration to union, but it also doesn't work. Also tried to remove return type relying on TS to infer the type of returned mapper function, but in this case type checking doesn't work when toEntity is called.


Solution

  • The TypeScript type checker is not really able to reason about what values might or might not be assignable to a conditional type that depends on an as-yet unspecified generic type parameter. It defers evaluation of such types. Inside the body of toEntity(), the type

    T extends Partner.Google ? typeof mapFromGoogle : typeof mapFromMicrosoft
    

    is such a generic conditional type, and so the compiler is not sure what it's going to be... it doesn't know exactly what T is, so it knows almost nothing about T extends Partner.Google ? typeof mapFromGoogle : typeof mapFromMicrosoft.

    You might think that your switch/case statements that check the partner parameter against the different possibilities would help narrow/re-constrain T, but this just doesn't happen, at least as of TypeScript 4.9.

    The canonical feature request for something better is microsoft/TypeScript#33912. It's been open for quite a while and there's no indication when it might be implemented, if ever.

    For now, if you want to proceed, you'll need to work around it.


    The best workaround in my opinion is to refactor your operations so that they are represented as a generic property lookup instead of a generic switch/case. The compiler understands generic indexed access types in a way it does not understand generic conditional types.

    Here's one way to write that:

    const partnerMap = {
      [Partner.Google]: mapFromGoogle,
      [Partner.Microsoft]: mapFromMicrosoft
    }
    type PartnerMap = typeof partnerMap;
    function toEntity<K extends Partner>(partner: K): PartnerMap[K] {
      return partnerMap[partner];
    }
    

    The partnerMap object encodes the desired input-output relationship of toEntity() as key-value pairs. The toEntity call signature says that the return type is related to the input type via a lookup into the type of partnerMap. And the implementation has been changed to match. This compiles because the type checker agrees that looking up a key of type K in a value of type PartnerMap will yield a value of type PartnerMap[K].


    And let's just make sure that it works as desired from the caller's side:

    const g = toEntity(Partner.Google); 
    // const g: (dto: GoogleDto) => Entity
    const m = toEntity(Partner.Microsoft);
    // const m: (dto: MicrosoftDto) => Entity
    

    Looks good; this part hasn't changed from your version, so all the test cases behave the same.

    Playground link to code