typescript

Omit returns in clauses that are always false for the given type


Note, I'm unfortunately dependent on TS 5.4.4

A simplified version of a problem I'm facing could be as follows:

const isNumber = (value:unknown): value is number => typeof value === 'number'
const isString = (value:unknown): value is string => typeof value === 'string'

const doSomethingWithString = (str: string) => 'This is what should happen to a string'  as const
const doSomethingWithNumber = (str: number) => 'Is this really a number?' as const

const doSomething = <T extends string|number>(arg:T) => {
    if(isNumber(arg)) {
        return doSomethingWithNumber(arg)
    }
    if(isString(arg)) {
        return doSomethingWithString(arg)
    }
    throw new Error('Unreachable part of the code reached!')
}

const test = doSomething('string')
const testNum = doSomething(1)

Playground link

The type of both test and testNum is "Is this really a number?" | "This is what should happen to a string". What I want is to somehow get the return type to be exclusively "This is what should happen to a string" if I pass a string as the argument.

From what I understand TypeScript doesn't omit clauses that are never, but omits return types that are never:

const isNumber = (value:unknown): value is number => typeof value === 'number'

const doSomething = (a:string)=>{
    if(isNumber(a)) {
        // Here typescript correctly recognizes that `a` is `never`
        // But still includes the return as a possible return type
        return `This is never: ${a}`
    }
    return 'This is the correct return' as const
}

const doSomethingElse = (a:string)=>{
    if(isNumber(a)) {
        // Here the return itself is `never` so it's not included
        // the return type of the function is correct
        return a
    }
    return 'This is the correct return' as const
}

Playground link

So, my naive implementation would be:

const isNumber = (value:unknown): value is number => typeof value === 'number'
const isString = (value:unknown): value is string => typeof value === 'string'

const doSomethingWithString = (str: string) => 'This is what should happen to a string'  as const
const doSomethingWithNumber = (str: number) => 'Is this really a number?' as const

type NeverByPredicate<P, T> = P extends never ? never : T;

const doSomething = <T extends string|number>(arg:T) => {
    if(isNumber(arg)) {
        const res = doSomethingWithNumber(arg)
        return res as NeverByPredicate<typeof arg, typeof res>
    }
    if(isString(arg)) {
        const res = doSomethingWithString(arg)
        return res as NeverByPredicate<typeof arg, typeof res>
    }
    throw new Error('Unreachable part of the code reached!')
}

const test = doSomething('string')
const testNum = doSomething(1)

Playground link

It works! But it also includes ugly repetition and an assertion that doesn't need to be true (if someone changes the predicate in the if statement but doesn't change it in the return type generic). Is there any more elegant solution to this?


Solution

  • You are trying to make your generic function return a conditional type, where TypeScript uses control flow analysis to determine which branch of the conditional type a return statement is taking. That unfortunately is not supported in TypeScript 5.7 and below, and is the subject of the feature request at microsoft/TypeScript#33912

    In TypeScript 5.8, a feature implemented at microsoft/TypeScript#56941 will address this, and allow you to return generic conditional types of a particular form, and TypeScript will verify that your function body conforms to your return type. It could look like this for your example:

    type StrRet = ReturnType<typeof doSomethingWithString>;
    type NumRet = ReturnType<typeof doSomethingWithNumber>;
    
    const doSomething = <T extends string | number>(
        arg: T
    ): T extends number ? NumRet : T extends string ? StrRet : never => {
        if (isNumber(arg)) {
            return doSomethingWithNumber(arg)
        }
        if (isString(arg)) {
            return doSomethingWithString(arg)
        }
        throw new Error('Unreachable part of the code reached!')
    }
    

    That compiles and behaves the way you want. But unfortunately the question says it can't use TypeScript 5.8, so we're stuck.


    The only thing we can do in older version is use a type assertion on the value returned to just tell TypeScript that it's the right type. You can try to put that type assertion in one reusable place, but it's still there. A slightly modified version of your code where I move the assertion into a separate function is:

    function callNeverByPredicate<P, T>(
        arg: P, fn: (a: P) => T
    ): NeverByPredicate<P, T> {
        return fn(arg) as any;
    }
    
    const doSomething = <T extends string | number>(arg: T) => {
        if (isNumber(arg)) {
            return callNeverByPredicate(arg, doSomethingWithNumber);
        }
        if (isString(arg)) {
            return callNeverByPredicate(arg, doSomethingWithString);
        }
        throw new Error('Unreachable part of the code reached!')
    }
    

    This behaves very much like your example. I used the any type in my type assertion but it's the same idea. The function callNeverByPredicate takes arg and the function fn you call it on, and returns NeverByPredicate<typeof arg, typeof res> where res is fn(arg).

    Playground link to code