Is there a way to keep type inference in union types from type guards inside a function?

194 Views Asked by At

Let's say I have three types (and a union one)

type A = {
    type: 'a'
    title: string
    description: string
}

type B = {
    type: 'b'
    title: string
}

type C = {
    type: 'c'
    description: string
}

type D = A | B | C

I know I can get a correct type inference with the === operator

function logger(t: D) {
    if (t.type === 'a' || t.type === 'b') console.log(t.title) // no problems here
    if (t.type === 'a' || t.type === 'c') console.log(t.description) // no problems here
}

However, would it be possible to write an utility function:

function matches<T extends { type: string }>(t: T, types: T['type'][]) : boolean {
    return types.includes(t.type)
}

So I can do this without errors?

function logger(t: D) {
    if (matches(t, ['a', 'b'])) console.log(t.title) // Property 'title' does not exist on type D
    if (matches(t, ['a', 'c'])) console.log(t.description) // Property 'description' does not exist on type D
}
1

There are 1 best solutions below

1
On BEST ANSWER

You could write matches() as a user-defined type guard function so that the compiler understands your intent for the true/false output of the function to narrow the type of the t parameter.

Here's one way to do it:

function matches<T extends { type: string }, K extends string>(
    t: T, types: K[]
): t is Extract<T, { type: K }> {
    const widenedTypes: readonly string[] = types;
    return widenedTypes.includes(t.type);
}

This is generic both in T, the type of t, but also K, the union of string literal types of the elements of types. The return type is Extract<T, {type: K}>, which uses the Extract<T, U> utility type to filter the union type T to just those constituents assignable to {type: K}.

Note that the compiler will complain about types.includes(t.type) because the elements of types are narrower than t.type. The way I deal with that is to first (safely) widen types from K[] to readonly string[] and then call includes() on that. See this question and its answers for more information.


Let's see if it works:

function logger(t: D) {
    if (matches(t, ['a', 'b'])) console.log(t.title) // okay
    if (matches(t, ['a', 'c'])) console.log(t.description) // okay
}

Looks good!

Playground link to code