Reputation: 2196
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
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.
Upvotes: 1