Narrow return type of find from discriminated union array
Asked Answered
A

7

16

I often use code like in example below and was wondering if there is some smart way to type the find results without having to do explicit type assertion.

type Foo = { type: "Foo" };
type Goo = { type: "Goo" };
type Union = Foo | Goo;

const arr: Union[] = [];
const foo = arr.find(a => a.type === "Foo") as Foo;

If the as Foo type assertion is left out, the result is of type Union even though it can only return type Foo.

What's the cleanest way of fixing the type of find to return the narrowed type in examples like these?

Edit: This problem could be also applicable to filter and other similar methods.

Edit2: Suggested similar question's accepted answer (Way to tell TypeScript compiler Array.prototype.filter removes certain types from an array?) shows that by using type guard in the predicate for find/filter the return value can be narrowed down.

How should this type guard function look to narrow down any discriminated union if e.g. the distinguishing string literal is always under type key?

Antependium answered 26/11, 2019 at 11:35 Comment(6)
Does this answer your question? Way to tell TypeScript compiler Array.prototype.filter removes certain types from an array?Aquacade
Not completely but it helps. Now just to figure out how to write some generic type guard function that can narrow down discriminated unions like this so I would be able to write arr.find(isTypeFromUnion("Foo")).Flaming
You could define the callback function of find as type guard like this: const maybeFoo = arr.find((a): a is Foo => a.type === "Foo"); // Foo | undefined . TSAquacade
That just moves the as Foo to a is Foo which means I would still need to explicitly write the typeFlaming
Afaik there is currently no way for the compiler to automatically infer the type guard and narrow the union, see here and this issue.Aquacade
What I'm asking now is how to write a type guard that narrows discriminated unions just like it is narrowed in e.g. switch over it's typeFlaming
Z
4

By now we can have it as a reasonably generic util:

export function isOfType<
  GenericType extends string,
  Union extends { type: GenericType },
  SpecificType extends GenericType,
>(val: SpecificType) {
  return (obj: Union): obj is Extract<Union, { type: SpecificType }> =>
    obj.type === val;
}

const found = arr.find(isOfType('Foo'));

Inspired by Trevor Dixon via https://stackoverflow.com/a/61102770

Zedekiah answered 16/3, 2023 at 19:32 Comment(2)
I didn't know you can use Extract that way. I would expect you only get { type: SpecificType } | undefined as a return type of the .find but you actually get the whole thing. That's absolutely amazing, thanks!Flaming
This is awesome! How would something similar look like for an array of types, e.g. arr.find(isOfTypes(['Foo', 'Bar']))?Rubalcava
A
14

If you want a generator for user-defined type guard functions returning a type predicate that discriminates discriminated unions, it might look something like this:

function discriminate<K extends PropertyKey, V extends string | number | boolean>(
    discriminantKey: K, discriminantValue: V
) {
    return <T extends Record<K, any>>(
        obj: T & Record<K, V extends T[K] ? T[K] : V>
    ): obj is Extract<T, Record<K, V>> =>
        obj[discriminantKey] === discriminantValue;
}

If I call discriminate("type", "Foo"), the result is a function with a signature similar to <T>(obj: T)=>obj is Extract<T, {type: "Foo"}>. (I say it's similar because the actual return value restricts T to just types with "type" as a key and a value you can assign "Foo" to.) Let's see how it works:

const foo = arr.find(discriminate("type", "Foo")); // Foo | undefined 
const goos = arr.filter(discriminate("type", "Goo"));  // Goo[]

Looks good. And here's what happens if you pass inapplicable field/values:

const mistake1 = arr.find(discriminate("hype", "Foo")); // error!
// ------------------->   ~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Union is not assignable to Record<"hype", any>.
const mistake2 = arr.find(discriminate("type", "Hoo")); // error!
// ------------------->   ~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Union is not assignable to ((Foo | Goo) & Record<"type", "Hoo">)

Okay, hope that helps; good luck!

Link to code

Arietta answered 26/11, 2019 at 15:29 Comment(2)
Yes this works like charm. Exactly what I expected. Could you also expand this to negation and union? a.type !== "Foo" and a.type === "Foo" || a.type === "Goo"Flaming
Yes, most likely. Negation would use Exclude instead of Extract, and for union you'll need to accept an array/rest parameter of type V[] and check each element in the array.Arietta
A
7

Here is the jcalz's code from answer above extended with the negation and union.

export function isDiscriminate<K extends PropertyKey, V extends string | number | boolean>(
    discriminantKey: K, discriminantValue: V | V[]
) {
    return <T extends Record<K, any>>(
        obj: T & Record<K, V extends T[K] ? T[K] : V>
    ): obj is Extract<T, Record<K, V>> =>
        Array.isArray(discriminantValue) 
            ? discriminantValue.some(v => obj[discriminantKey] === v)
            : obj[discriminantKey] === discriminantValue;
}

export function isNotDiscriminate<K extends PropertyKey, V extends string | number | boolean>(
    discriminantKey: K, discriminantValue: V | V[]
) {
    return <T extends Record<K, any>>(
        obj: T & Record<K, V extends T[K] ? T[K] : V>
    ): obj is Exclude<T, Record<K, V>> =>
        Array.isArray(discriminantValue)
            ? discriminantValue.some(v => obj[discriminantKey] === v)
            : obj[discriminantKey] === discriminantValue;
}

And usage:

type A = { type: "A" };
type B = { type: "B" };
type C = { type: "C" };
type Union = A | B | C;

const arr: Union[] = [];
arr.find(isDiscriminate("type", "A")); // A
arr.find(isDiscriminate("type", ["A", "B"])); // A | B
arr.find(isNotDiscriminate("type", "A")); // B | C
arr.find(isNotDiscriminate("type", ["A", "B"])) // C
Antependium answered 27/11, 2019 at 11:15 Comment(0)
P
7

The accepted answer is excellent, but it's hard to understand. I went with something like this. It's less reusable, only working on one type, but it's much easier to read.

function isType<V extends Union['type']>(val: V) {
  return (obj: Union):
      obj is Extract<Union, {type: V}> => obj.type === val;
}

const found = arr.find(isType('Foo'));
Pincer answered 8/4, 2020 at 14:11 Comment(0)
M
4

Thank you to jcalz for the excellent accepted answer.

I have annotated a slightly different flavor of a factory for predicate guards:

function hasProp<K extends PropertyKey, V extends string | number | boolean>(k: K, v: V) {
  
  // All candidate types might have key `K` of any type
  type Candidate = Partial<Record<K, any>> | null | undefined

  // All matching subtypes of T must have key `K` equal value `V`
  type Match<T extends Candidate> = Extract<T, Record<K, V>>

  return <T extends Candidate>(obj: T): obj is Match<T> => (
    obj?.[k] === v
  )
}
Myungmyxedema answered 10/9, 2020 at 14:22 Comment(0)
Z
4

By now we can have it as a reasonably generic util:

export function isOfType<
  GenericType extends string,
  Union extends { type: GenericType },
  SpecificType extends GenericType,
>(val: SpecificType) {
  return (obj: Union): obj is Extract<Union, { type: SpecificType }> =>
    obj.type === val;
}

const found = arr.find(isOfType('Foo'));

Inspired by Trevor Dixon via https://stackoverflow.com/a/61102770

Zedekiah answered 16/3, 2023 at 19:32 Comment(2)
I didn't know you can use Extract that way. I would expect you only get { type: SpecificType } | undefined as a return type of the .find but you actually get the whole thing. That's absolutely amazing, thanks!Flaming
This is awesome! How would something similar look like for an array of types, e.g. arr.find(isOfTypes(['Foo', 'Bar']))?Rubalcava
G
0

To add autocomplete for the "types" (extension of @edzis answer)

export function isOfType<
  Union extends { type: string },
  SpecificType extends Union['type'],
>(val: SpecificType) {
  return (obj: Union): obj is Extract<Union, { type: SpecificType }> =>
    obj.type === val;
}


const found = arr.find(isOfType('Foo'));


Gum answered 27/9, 2023 at 14:25 Comment(0)
V
0

Extension of @edzis and @SEG answers:

To narrow to a subset of types:

export function isOfType<
  Union extends { type: string },
  SpecificType extends Union['type'],
>(val: SpecificType[] | SpecificType) {
  return (obj: Union): obj is Extract<Union, { type: SpecificType }> =>
    Array.isArray(val) ? val.some((a) => a === obj.type) : obj.type === val;
}
Vedette answered 29/2 at 20:9 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.