Search code examples
typescripttype-narrowing

Stricter narrowing in TypeScript that is only allowed if the narrowed case is possible


In the following snippet

interface C1 { kind: 'c1' }
interface C2 { kind: 'c2' }
interface C3<T> { kind: 'c3'; value: T }

function isC3<T, CX extends ([C3<T>] extends [CX] ? { kind: string; } : never)>(
    c: CX
): c is C3<T> /* <------- PROBLEM !!! */ {
    return c.kind === 'c3';
}

// valid use
type C = C1 | C2 | C3<number>;
isC3<number, C>({ kind: 'c3', value: 1 }); // true
isC3<number, C>({ kind: 'c1' });           // false


// invalid use
type D = C1 | C2;
declare var d: D;
isC3<number, D>(d); // D doesn't satisfy constraint never (as expected)

I am trying to make the isC3 typeguard function in such a way that a call to it only compiles if the type of the given type argument has C3 case in it, otherwise I would like to get a compilation error (please look at the examples)

So everything works, except that I get an error at the typeguard return type:

A type predicate's type must be assignable to its parameter's type.
  Type 'C3<T>' is not assignable to type 'CX'.
    'C3<T>' is assignable to the constraint of type 'CX', but 'CX' could be instantiated with a different subtype of constraint '{ kind: string; }'.ts(2677)

enter image description here

Is there a way to get what I am looking for?


Solution

  • If you have a type T that you know is assignable to U but the compiler does not know this, and you need to use T in a place the compiler expects something assignable to U, you can either use the Extract<T, U> utility type or possibly Extract<U, T>, or you can use the intersection T & U, depending on your use case:

    declare function isC3<T, CX extends ([C3<T>] extends [CX] ? { kind: string; } : never)>(
        c: CX
    ): c is Extract<CX, C3<T>> // okay
    

    or

    function isC3<T, CX extends ([C3<T>] extends [CX] ? { kind: string; } : never)>(
        c: CX
    ): c is CX & C3<T> {
        return c.kind === 'c3';
    }
    

    Both of these end up resulting in a type which is effectively C3<T>. Extract<T, U> filters a union in T to just those members assignable to U (so if you expect CX to be a union, then Extract<CX, C3<T>> is reasonable), while T & U is more agnostic about unions.

    Playground link to code