javascripttypescriptpredicate

return type narrowing from array of predicates


classes with static check methods. wanted to find the matching one and return to it in wrap method.

this architecture perfectly suits for my need of OOP-ifying. here is the way that I used to do it in JavaScript:

class A {
    static check = arg => typeof arg == "number"
    constructor(arg) { this.value = arg}
    log = () => console.log("A/", this.value)
}

class B {
    static check = arg => typeof arg == "string"
    constructor(arg) { this.note = arg }
    log = () => console.log("B/", this.note)
}

function wrap(arg, classes) {
    for (const Class of classes) 
        if (Class.check(arg))
            return new Class(arg)
}

const classes = [A, B]

wrap("adaptive", classes).log()
wrap(999, classes).log()

lately, I thought maybe TypeScript could help me with that. what I expected was that the wrap call would indicate the return value (since it's all about types with no logic)

unfortunately, it was not the case. the closest I could get was that:

type PayloadA = number
type PayloadB = string
type PayloadGeneric = PayloadA | PayloadB

class A {
    static check(arg:PayloadGeneric): arg is PayloadA {
        return typeof arg == "number"
    }
    value: number
    constructor(arg: PayloadA) { this.value = arg}
    log = () => console.log("A/", this.value)
}

class B {
    static check(arg:PayloadGeneric): arg is PayloadB {
        return typeof arg == "string"
    }
    note: string
    constructor(arg: PayloadB) { this.note = arg}
    log = () => console.log("B/", this.note)
}

function wrap(arg: PayloadGeneric) {
    if (A.check(arg)) {   // understands that arg is number
        return new A(arg) // and that arg is suitable for PayloadA
    } 
    if (B.check(arg)) {   // understands that arg is string
        return new B(arg) // and that arg is suitable for PayloadB
    }
}

const instanceA = wrap(999) // but here it doesn't understand the type
const instanceB = wrap("adaptive") // neither here

(I know PayloadA and PayloadB may look silly since they're just number and string but the whole point is about having custom interfaces and predicates. number and string is just for the example and wrapping them into a type demonstrates my purpouse better)

look wrap can only accept PayloadA or PayloadB. inside, we have 2 ifs. one checks for PayloadA and understands and returns, the other checks for PayloadB and again understands well and returns. but TypeScript doesn't stop there and continues reading the rest, like it may get executed. even thinks it may return undefined since I'm not handling the default return, but logically, I do - I only accept A or B and handled both. so,

I'm handling all the cases but TypeScript doesn't get it. how do I fix it? I want something like return type narrowing.

and please don't tell me "just put return type A | B" I don't wanna hardcode cuz the given list can change. I may put classes that none of their check method matches, in this case I'd like to see that the return type is going to be undefined. or I put one that matches, then I'd like to see what the return type is gonna be.

thanks in advance


Solution

  • If you have an array of such class constructors, like

    const checkers = [A, B] as const;
    

    then you can use it to at least express the intended call signature for wrap(), as a generic function that returns a conditional type:

    type Check<T, C = typeof checkers> = C extends readonly [
        { check(arg: any): arg is infer P, new(arg: any): infer I }, ...infer R
    ] ? T extends P ? I : Check<T, R> : undefined;
    
    declare function wrap<T extends PayloadGeneric | {} | null | undefined>(arg: T): Check<T>;
    

    If you inspect the type Check<T> for generic T, you get:

    type Z<T> = Check<T>;
    // type Z<T> = T extends PayloadA ? A : T extends PayloadB ? B : undefined
    

    So if you call wrap(arg) where arg is of type PayloadA, then the return type is A; otherwise, if you call it where arg is of type PayloadB, then the return type is B; otherwise the return type is undefined. That gives the desired behavior, even if arg is of a union type:

    const instanceA = wrap(999)
    //    ^? const instanceA: A
    console.log(instanceA.value.toFixed())
    
    const instanceB = wrap("abc");
    //    ^?
    console.log(instanceB.note.toUpperCase());
    
    const notInstance = wrap(new Date());
    //    ^? const notInstance: undefined
    
    const union = wrap(Math.random() < 0.5 ? 999 : new Date());
    //    ^? const union: A | undefined
    

    Note that the constraint on T in wrap() is PayloadGeneric | {} | null | undefined, which is effectively no constraint at all; it's a verbose way of expressing unknown. We don't really want to force callers of wrap() to pass in a PayloadGeneric (otherwise you'd never get undefined), so unknown is the intended constraint (note that if you do want to force such things then you can just make T extends PayloadGeneric, but then undefined is impossible)... but since the constraint includes PayloadGeneric it gives TypeScript a hint to interpret arg as a PayloadGeneric if possible. This handles cases where arg's type would normally be inferred differently (such as if arg is a string literal like "k", and you actually want the literal type "k" instead of the default string inferred). It's a complication that isn't really motivated by the question as asked, but can handle edge cases such as were raised in the comments. (That makes this section technically off-topic, but I'll leave it to hopefully minimize followup questions.)


    So, it works as desired; let me briefly explain what Check<T> does:

    type Check<T, C = typeof checkers> = C extends readonly [
        { check(arg: any): arg is infer P, new(arg: any): infer I }, ...infer R
    ] ? T extends P ? I : Check<T, R> : undefined;
    

    This is a recursive conditional type that accepts two type arguments: the first is T, intended to be the type of arg in wrap(); the second is C, intended to be a tuple of "checker" class constructor types. (The const assertion in const checkers = ⋯ as const makes sure that checkers is a tuple type, so TypeScript knows the order of the elements.)

    So Check<T, C> looks at the C tuple. If it has at least one element, then we look at its first element to infer the payload type P and the instance type I, and then the type we want is T extends P ? I : ⋯. So if the first element is typeof A, then the type is T extends PayloadA ? A : ⋯. And in place of we have the recursion Check<T, R> where R is the rest of the C tuple after the first element. If C has no elements, then we have no more checkers left and the type evaluates to the base case of undefined.


    Finally, to implement wrap():

    function wrap<T extends PayloadGeneric | {} | null | undefined>(arg: T): Check<T>;
    function wrap(arg: any) {
        for (const check of checkers) {
            if (check.check(arg)) {
                return new check(arg as never)
            }
        }
        return undefined;
    }
    

    I've written this as a single-call-signature overload, in order to separate the strongly-typed call signature from the weakly-typed implementation. TypeScript is currently (and will probably always be) unable to verify that the loop inside the implementation satisfies the call signature. It's just too complicated; maybe it could be rewritten as a recursive function and when TypeScript 5.8 introduces microsoft/TypeScript#56941 there will be some way to write it with compiler-verified type safety, but even so it's probably not worth it. By writing it as an overload, I've placed a barrier between call safety and implementation safety... and the implementation safety is our responsibility, not TypeScript's. If we're confident that it's written correctly, then we can just use whatever type assertions we need to get it compiling (arg as never, for example).


    Playground link to code