Reputation: 4583
Background
I've been using the Exclude
and Extract
utility types but have come across a case where i only want to match exact types not subtypes.
So far
I've managed to create a StrictExtract
utility type that only extracts types that are an exact match - although there's possibly an easier way to do this?
type StrictExtract<T, U> =
T extends unknown ?
U extends T ?
T extends U ?
T :
never :
never :
never;
Examples
type objOne = {
prop1: string;
prop2: number;
}
type objTwo = {
prop1: string;
prop2: number;
prop3: Function;
}
type ext1 = Extract<objOne | objTwo | string | number, string | number | objOne>
// string | number | objOne | objTwo
type stext1 = StrictExtract<objOne | objTwo | string | number, string | number | objOne>
// string | number | objOne
So where as Extract
would also match objTwo
as it's a subtype of objOne
, StrictExtract
accepts the same type parameters, but only extracts exact type matches.
So StrictExclude
would mirror the input parameters of Exclude<Type, Union>
, but would only exclude types that where an exact match.
type excl = Exclude<objOne | objTwo | string | number, objOne | string | number>
// never
type strExcl = StrictExclude<objOne | objTwo | string | number, objOne | string | number>
// should result in objTwo
Problem
I've tried to use the same approach to work out the logic for StrictExclude
but have been going round in circles for a while now.
I started off with the following utility to get an understanding of what's output by each condition. I can see how to calculate which types need to be removed - essentially by using StrictExtract
- but not how to then remove those exact types from the union of T
... (╯°□°)╯︵ ┻━┻
type StrictExclude<T, U> =
T extends unknown ?
(U extends T ?
(T extends U ?
(tok: T) => U :
(ux: U) => T) :
(u2x: U) => T) :
never;
Question
How do you create a strict exclude utility type, that only removes exact type matches, not subtypes?
Upvotes: 3
Views: 487
Reputation: 329923
First, it seems like you want StrictExclude<T, U>
to distribute across unions in T
, so if T
is A | B | C
, then StrictExclude<A | B | C, U>
is equivalent to StrictExclude<A, U> | StrictExclude<B, U> | StrictExclude<C, U>
. So as a first step we can write this as a distributive conditional type:
type StrictExclude<T, U> = T extends unknown ? StrictExcludeInner<T, U> : never;
where
StrictExcludeInner<T, U>
performs the desired operation on non-union types T
. The type U
might still be a union, and we need to think carefully about what to do in this case. The goal is to take (a non-union) T
and compare it to each union element of U
; if we find any such element where T
and U
are mutually assignable (so T extends U
and U extends T
are both true), then we want to return never
. On the other hand, if T
is not mutually assignable with any union element of U
, then we want to return T
. It is this "mutual assignability" that seems to be what you mean by "strict" in StrictExclude
.
For example, assuming D
, E
, and F
are distinct non-union types where no pair of them are mutually assignable, then StrictExcludeInner<D, D | E>
should be never
, but StrictExcludeInner<F, D | E>
should be F
.
We can write that like this:
type _StrictExcludeInner<T, U> = 0 extends (
U extends T ? [T] extends [U] ? 0 : never : never
) ? never : T;
Let's first examine the middle chunk of that:
U extends T ? [T] extends [U] ? 0 : never : never
This is a distributive conditional type in U
. (I have suppressed the distributivity over unions in T
by writing [T] extends [U]
instead of T extends U
, but since we expect T
not to be a union, then it doesn't really matter.) Each union element of U
which is mutually assignable with T
will end up contributing a 0
to the final type, and each element of U
which is not mutually assignable with T
will end up contributing a never
to the final type. Since 0 | never
is 0
, then U extends T ? [T] extends [U] ? 0 : never : never
will evaluate to 0
if and only if at least one element of U
is mutually assignable with T
. Otherwise it will evaluate to never
.
So now let's look at the full type of StrictExcludeInner<T, U>
. If U
has at least one element that mutually assigns with T
, then it will evaluate to 0 extends (0) ? never : T
... since 0 extends 0
is true, this will evaluate to never
, as desired. On the other hand, if U
has no elements that mutually assign with T
, then it will evaluate to 0 extends (never) ? never : T
. Since 0 extends never
is false, this will evaluate to T
, also as desired.
Okay, let's test it on your examples:
type strExcl = StrictExclude<objOne | objTwo | string | number, objOne | string | number> // objTwo
type six = StrictExclude<MyClassOne | MyClassTwo, MyClassOne | MyClassTwo> // never
type seven = StrictExclude<MyClassOne | MyClassTwo | string, MyClassOne | MyClassTwo> // string
type eight = StrictExclude<MyClassOne | MyClassTwo | string, MyClassOne> // string | MyClassTwo
type nine = StrictExclude<MyClassOne | MyClassTwo | string, MyClassTwo> // string | MyClassOne
These all evaluate to the types you expected. Hooray!
Upvotes: 2