Search code examples
typescripttypeguards

TypeScript type guards. How to get type narrowing AND suggestions?


I have assertIsDefined type guard with expected value. I need to type narrowing to expected value with type suggestions.

  • For type narrowing i need to add | unknown to generic type, and suggestions is not showing with it.
  • Without | unknown in generic type, suggestions are showing, but type narrowing is not working.

What should I do with my type guard to make it work like a shine?

{
    const value_number1 = 123 as 3 | 123 | 777 | undefined;
    //     ^?
    // 😪 INCURRECT BEHAVIOUR: remove '123' from ', 123' and 'Ctrl+Space' to no suggestions showed 😪
    assertIsDefined_withDetailedType(value_number1, 123);
    // 🌈 CURRECT BEHAVIOUR: calculated type of value_number1 is 123 🌈
    console.log(value_number1);// value_number1: 123
    //           ^?
}
{
    const value_number2 = 123 as 3 | 123 | 777 | undefined;
    //     ^?
    // 🌈 CURRECT BEHAVIOUR: remove '123' from ', 123' and 'Ctrl+Space' to show SUGGESTIONS: should be [ 123, 3, 777 ] 🌈
    assertIsDefined_withSuggestions(value_number2, 123);
    // 😪 INCURRECT BEHAVIOUR: calculated type of value_number2 is 3 | 123 | 777 😪
    console.log(value_number2);// value_number2: 3 | 123 | 777
    //           ^?
}

function assertIsDefined_withDetailedType<T>(value: T | unknown | null | undefined, expected: T): asserts value is T {
    if (value == null || expected !== value) {
        throw new TypeError(`value should be defined and equal expected value`);
    }
}

function assertIsDefined_withSuggestions<T>(value: T | null | undefined, expected: T): asserts value is T {
    if (value == null || expected !== value) {
        throw new TypeError(`value should be defined and equal expected value`);
    }
}

I expect:

{
    const value_number2 = 123 as 3 | 123 | 777 | undefined;
    //     ^?
    // 🌈 CURRECT BEHAVIOUR: remove '123' from ', 123' and 'Ctrl+Space' to show SUGGESTIONS: should be [ 123, 3, 777 ] 🌈
    assertIsDefined_withAllWeWant(value_number2, 123);
    // 🌈 CURRECT BEHAVIOUR: calculated type of value_number2 is 123 🌈
    console.log(value_number2);// value_number2: 123
    //           ^?
}

// Some gorgeous type guard
function assertIsDefined_withAllWeWant(value: T/*code here?*/, expected: T): asserts value is T {
    if (value == null || expected !== value) {
        throw new TypeError(`value should be defined and equal expected value`);
    }
}


Solution

  • I would write assertIsDefined() to be generic in both the type T of value and the type U of expected, where U is constrainted to both T (so it's a narrowing) and {} (so it's NonNullable; an intersection with the empty object type only prohibits null and undefined):

    function assertIsDefined<T, U extends T & {}>(
        value: T, expected: U): asserts value is U {
        if (value == null || expected !== value) {
            throw new TypeError(`value should be defined and equal expected value`);
        }
    }
    

    Now you can only specify an expected as some non-nullish subtype of the type of value, and in TS5.4 and above you get nice autosuggestions as well:

    const value = 123 as 3 | 123 | 777 | undefined;
    //     ^? const value: 3 | 123 | 777 | undefined;
    
    assertIsDefined(value, 123); // <-- autosuggest 3 | 123 | 777
    
    console.log(value);
    //           ^? const value: 123
    

    Playground link to code