K.Steff
K.Steff

Reputation: 2196

Using Typescript’s type system to create strongly typed filter functions

I’m trying to use the TypeScript type system to create a very strongly typed ‘filter’-like function that operates on a collection (not a simple array though). Below is an example of what I’m trying to achieve:

type ClassNames = 'B' | 'C' | 'D'; // I know the COMPLETE list 

declare class A {
    protected constructor();
    className: ClassNames;
    attr1: string;
}

declare class B extends A {
    private constructor();
    className: 'B';
    attr2: string;
    attr4: number;
}

declare class C extends A {
    private constructor();
    className: 'C';
    attr3: number;
    attr4: string;
}

declare class D extends A {
    private constructor();
    className: 'D';
    attr4: string | number;
}

declare class Filter<T> {
    has<U extends keyof (A|B|C|D), V extends U[keyof U]>(propName: U, propVal: V): Filter<T & {U: V}>;
    //                                                                                         ^ This is obviously wrong
    all(): T[];
}

let g = new Filter<A>();
let x = g.has('className', 'B');
let y = g.has('attr4', 'whatever');
type Y = typeof y; // I want this to be Filter<C | D>
type X = typeof x; // I want this to be Filter<B>

A few notes:

I’ve tried using conditional types, but to no avail (my knowledge here is probably lacking). I also feel infer might be relevant to the solution, but I can’t see where to put it or how to use it.

Is this achievable?

Upvotes: 2

Views: 207

Answers (1)

jcalz
jcalz

Reputation: 330571

My suggestion is to refactor to something like this. First, it's helpful to have a type corresponding to the union of all acceptable subclasses of A; I've called this Classes. From this you can compute ClassNames if it turns out you need that type:

type Classes = B | C | D;
type ClassNames = Classes["className"];

Then we need to define some utility types to help describe what we want Filter<T> to do.


Given a union type T, we'd like AllKeys<T> to give us the union of keys that appear in any member of the union T. A straightforward keyof T doesn't work because a value of type {a: string, c: string} | {b: number, c: string} is only known to have a key named c; it may or may not have an a key, so keyof can only return "c" here. We want "a" | "b" | "c" instead. So AllKeys<T> has to distribute keyof over unions in T. Here's how:

type AllKeys<T> =
  T extends unknown ? keyof T : never;

That's a distributive conditional type.

We also need a similar way to perform indexed accesses on a union type T and a key K where not every member of T is known to have a key K. Call it SomeIdx<T, K>. Again, we can't do a straightforward T[K] because the compiler will not let you index into a type with a key it isn't known to have. And again, we need to distribute indexed accesses across unions in T:

type SomeIdx<T, K extends PropertyKey> =
  T extends unknown ? K extends keyof T ? T[K] :
  never : never;

And finally, we need to write Select<T, K, V> which selects the member(s) of union type T which is known to have a key K and where type V is an acceptable value for the property at that key. This is the filtering operation you're looking for. Yet again we need to distribute the operation across unions in T; for each such member we check if K is a known key and if V is assignable to the value type at that key:

type Select<T, K extends PropertyKey, V> =
  T extends unknown ? K extends keyof T ? V extends T[K] ? T :
  never : never : never;

There's our utility types, and now we can define Filter<T>:

declare class Filter<T extends Classes = Classes> {
  has<K extends AllKeys<T>, V extends SomeIdx<T, K>>(
    propName: K, propVal: V): Filter<Select<T, K, V>>
  all(): T[];
}

Note that we are restricting T to be assignable to Classes, the union of known subclasses of A. We don't want to allow A itself here, because we really don't want to see A appear in the type of the result of has(). And T defaults to Classes so the type Filter by itself means Filter<B | C | D>.

For a given T, the current set of subclasses of A that a Filter<T> has been narrowed to, we want the has() method to accept a propName argument of a type K that is constrained to be assignable to AllKeys<T> (that is, propName should be one of the known keys of any of the types in T). And we want it to accept a propVal argument of type V that is constrained to be assignable to SomeIdx<T, K> (that is, propVal should be of a type known to be appropriate for the propName key for at least one of the members of T). Finally, we return Filter<Select<T, K, V>>, thus narrowing T to just those members with a known key K and a value type appropriate for V.


We've defined it, let's test it out:

let g = new Filter(); // Filter<Classes>

let x = g.has('className', 'B');
type X = typeof x; // type X = Filter<B>

let y = g.has('attr4', 'whatever');
type Y = typeof y; // type Y = Filter<C | D>

let z = x.has('attr3', 12345); // error!
// Argument of type '"attr3"' is not assignable to parameter of type 'keyof B'.

Looks good. Starting from a Filter<Classes>, we can narrow to Filter<B> by checking has('className', 'B'). Or we can narrow to Filter<C | D> by checking has('attr4', 'whatever') since both B and D will accept a string-valued attr4 property. And once we have a Filter<B>, it will only accept a propName from B, so "attr3" is rejected.

Playground link to code

Upvotes: 1

Related Questions