Search code examples
typescript

Type narrowing in list of assertions


I'm trying to combine a list of assertions in a type-safe way without having to call them all manually.

Example code:

type Base = { id: string, name?: string, age?: number };
type WithName = { name: string };
type WithAge = { age: number };

export type Asserter<T, U> = (x: T) => asserts x is T & U;

const assertName: Asserter<Base, WithName> = (x: Base): asserts x is Base & WithName => {
    if (x.name === undefined) {
        throw new Error("missing name");
    }
};

const assertAge: Asserter<Base, WithAge> = (x: Base): asserts x is Base & WithAge => {
    if (x.age === undefined) {
        throw new Error("missing age");
    }
};

type ExtractAssertions<T, U extends Asserter<T, any>[]> = 
    U extends [Asserter<T, infer V>, ...infer Rest]
        ? Rest extends Asserter<T, any>[]
            ? V & ExtractAssertions<T, Rest>
            : V
        : {};

function multiAssert<T, A extends Asserter<T, any>[]>(
  item: T,
  assertions: A
): asserts item is T & ExtractAssertions<T, A> {
  assertions.forEach(assertion => assertion(item));
}

const data: Base = { id: "aas-aa", name: "frank", age: 30 };

multiAssert(data, [assertName, assertAge]);

console.log(data.name[0]); // This should compile correctly
console.log(data.age + 3); // This should also be possible

I'm doing something wrong in the ExtractAssertions type, but I can't figure it out for the life of me.

Here's a link to a TS playground

I've tried a lot of variations on the recursive ExtractAssertions type, but they all either ended up back at the Base type, or led to never.


Solution

  • Your ExtractAssertions type is fine (although you can likely get it to work without being a recursive conditional type). The problem seems to be that when you call multiAssert(), the type of assertions and therefore A is inferred too widely for your purposes and is just an unordered array type. But you want it to be a tuple type so it feeds into ExtractAssertions as intended.

    You can affect this inference by making A a const type parameter, which gives TypeScript a hint that you want narrower inference:

    function multiAssert<T, const A extends Asserter<T, any>[]>(
        item: T,
        assertions: A
    ): asserts item is T & ExtractAssertions<T, A> {
        assertions.forEach(assertion => assertion(item));
    }
    

    And now it works as intended:

    const data: Base = { id: "aas-aa", name: "frank", age: 30 };    
    multiAssert(data, [assertName, assertAge]);
    // ^? function multiAssert<Base, [Asserter<Base, WithName>, Asserter<Base, WithAge>]>(⋯)
    data
    //^? const data: Base & WithName & WithAge
    

    Playground link to code