How do I Exclude or Select Tuples by membership?

42 views Asked by At

I have a tuple object with nested tuples

const foo = [
  { id: 't1', values: ['a', 'b'] },
  { id: 't2', values: ['a', 'c'] },
  { id: 't3', values: ['b', 'c'] },
] as const;

I want to be able to filter this based on tuple values but also discriminate the type.

this function would give you the right runtime value but gives a type error

var objsWithA = foo.filter(({ values }) => values.includes('a'));

If you bypass the type error using as any or as never the resulting type doesn't eliminate t3

var objsWithA = foo.filter(({ values }) => values.includes('a' as never));

Is it possible to get the resulting type to match the returned value? ie

[
  { id: 't1', values: ['a', 'b'] },
  { id: 't2', values: ['a', 'c'] },
]

ts playground link

1

There are 1 answers

0
jcalz On BEST ANSWER

TypeScript has some support for using the array filter() method to narrow the type of the resulting array, by invoking this call signature:

interface ReadonlyArray<T> {
  filter<S extends T>(
    predicate: (value: T, index: number, array: readonly T[]) => value is S,
    thisArg?: any
  ): S[];
}

meaning the predicate argument needs to be a custom type guard function whose return type is a type predicate.

Unfortunately TypeScript does not infer type predicates from function implementations, even in straightforward cases like `x => typeof x === "string". There is an open issue at microsoft/TypeScript#38390 asking for such support.

And even if that did happen, it probably wouldn't work for ({values})=>values.includes("a"); even a direct call to the includes() array method doesn't act as a type guard. This request was declined in microsoft/TypeScript#31018 because it's too complex to get it right.

That means you'd have to call filter() with a callback that you explicitly write to be a custom type guard function, and use it wisely. Here's one possible way to generate the type guard function for a slightly more general case than the one you have:

const objPropHasArrayContainingStringLiteral =
    <K extends string, T extends string>(
        propName: K,
        stringLiteral: T
    ) => <U extends Record<K, readonly string[]>>(
        obj: U
    ): obj is (
       U extends Record<K, readonly (infer V extends string)[]> ? 
       [T] extends [V] ? U : never : never
    ) => obj[propName].includes(stringLiteral);

Here objPropHasArrayContainingStringLiteral(propName, stringLiteral) produces a type guard function that checks an object for whether or not it has an array property at propName which contains stringLiteral.

The type output is using a distributive conditional type to filter a union type U to just those members whose property at key K is a readonly array containing T elements (at least, assuming T is a string literal type and that the array types of U themselves hold string literal types. It's tricky to get right, as mentioned above.

So for your example it would be objPropHasArrayContainingStringLiteral("values", "a"):

const objsWithA = foo.filter(objPropHasArrayContainingStringLiteral("values", "a"));

/* const objsWithA: ({
    readonly id: "t1";
    readonly values: readonly ["a", "b"];
} | {
    readonly id: "t2";
    readonly values: readonly ["a", "c"];
})[] */

That works, hooray. But I'm not sure the problem we're solving here is worth that more general solution. If you're only going to do this once, then you might as well just do it all inline:

const objsWithA = foo.filter(<U extends { values: readonly string[] }>(
    obj: U): obj is U extends { values: readonly (infer V extends string)[] } ?
    "a" extends V ? U : never : never => obj.values.includes("a")
);

which is the same thing, just with K hardcoded with "values" and T hardcoded as "a". Essentially it's saying that the callback filters the U union to just those members whose values property is a readonly array which might be holding a value of type "a".

And note well, the compiler really doesn't verify the implementation of custom type guard functions. You could change obj.values.includes("a") to !obj.values.includes("a") or obj.values.includes("whoops") and the compiler wouldn't notice. So again, you have to be careful.

Playground link to code