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)
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
}
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)
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?
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)
.