Get rid of type assertion when checking if value is part of union

I have a union type which is partially based on an array - which I also want to use to check values at run-time. However, TypeScript forces me to use a type-assertion here. Consider the code below:

const Pets = ["dog", "cat"] as const
type Pet = typeof Pets[number]

type Animal = Pet | "tiger"

function checkDanger(animal: Animal) {
  if (Pets.includes(animal as Pet)) {
        return "not dangerous"
    }
    return "very dangerous"
}

The problem is the as Pet part.

If I leave that out, I'll get: Argument of type 'Animal' is not assignable to parameter of type '"dog" | "cat"'. Type '"tiger"' is not assignable to type '"dog" | "cat"'.

However, in a more complex real-world situation, that assertion might have effects I'd like to avoid. Is there a way to do such a thing without an assertion?


Solution 1:

This is a known issue with includes, see issues/26255.

However, there is a workaround. You can create a custom curried typeguard:

const inTuple = <Tuple extends string[]>(
    tuple: readonly [...Tuple]) => (elem: string
    ): elem is Tuple[number] =>
        tuple.includes(elem)

// (elem: string) => elem is "dog" | "cat"
const inPets = inTuple(Pets)

Lets try it:

const Pets = ["dog", "cat"] as const
type Pet = typeof Pets[number]

type Animal = Pet | "tiger"

const inTuple = <Tuple extends string[]>(
    tuple: readonly [...Tuple]) => (elem: string
    ): elem is Tuple[number] =>
        tuple.includes(elem)

// (elem: string) => elem is "dog" | "cat"
const inPets = inTuple(Pets)

function checkDanger(animal: Animal) {
    if (inPets(animal)) {
        animal // "dog" | "cat"
        return "not dangerous"
    }
    return "very dangerous"
}

Since you have a conditional statement, I assume this function can be overloaded in order to narrow return type:

const Pets = ["dog", "cat"] as const
type Pet = typeof Pets[number]

type Animal = Pet | "tiger"

const inTuple = <Tuple extends string[]>(
    tuple: readonly [...Tuple]) => (elem: string
    ): elem is Tuple[number] =>
        tuple.includes(elem)

// (elem: string) => elem is "dog" | "cat"
const inPets = inTuple(Pets)


function checkDanger(animal: Pet): "not dangerous"
function checkDanger(animal: Animal): "very dangerous"
function checkDanger(animal: string) {
    if (inPets(animal)) {
        animal // "dog" | "cat"
        return "not dangerous"
    }
    return "very dangerous"
}

const result = checkDanger('tiger') // very dangerous
const result2 = checkDanger('cat') // not dangerous

Playground The order of overload signatures matter.

As you might have noticed, there are no type assertions in my code.

inTuple typeguard works, because tuple is treated as an array of strings inside function body. It means that this operation is allowed, since tuple[number] and elem are assignable to each other.

const inTuple = <Tuple extends string[]>(
    tuple: readonly [...Tuple]) =>
    (elem: string): elem is Tuple[number] => {
        tuple[2] = elem // ok
        elem = tuple[3] // ok
        return tuple.includes(elem)
    }

Solution 2:

Unfortunately, that is a limitation of the typescript compiler. However, instead of writing in thousand functions same line of animal as Pet you can utilize Type Guards as follows

function isPet(something: Pet | Animal) : something is Pet {
    return Pets.includes(something as Pet)
} 

function checkDanger(animal: Animal) {
    if (isPet(animal)) {
        return "not dangerous"
    }
    return "very dangerous"
}

This approach is more applicable since if you will rename your type definition name, it will be easier to refactor your code.

Still, if you declare a read-only array, you lose the flexibility, and type assertion has to be performed.