james
james

Reputation: 4583

How can i create a strict Exclude utility type?

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

Playground

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

Answers (1)

jcalz
jcalz

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!

Playground link to code

Upvotes: 2

Related Questions