Brooke Hart
Brooke Hart

Reputation: 4049

Using a conditional type to sometimes return the value passed as a function argument

I have a function that can take a number or a string, and always outputs either a number or null. If the function receives a number and returns a number, it will return the same number that it received.

Until now, I hadn't done anything to tell TypeScript that the same number would be returned, so I was losing some information but this worked just fine:

function getColNumber(colName: number | string): number | null {

But now I'd like to tell TypeScript about this restriction, so I've been trying to do this using a conditional type like this:

function getColNumber<T extends number | string>(colName: T): (T extends number ? T : number) | null {

However, TypeScript complains to me in each instance when I don't return null, telling me either "Type 'T & number' is not assignable to type 'T extends number ? T : number" or "Type 'number' is not assignable to type '(T extends number ? T : number) | null'"

I've tested this conditional type outside the function by creating some derived types using it, and TypeScript does seem to understand it in that circumstance. For example:

type SelfOrNumber<T> = T extends number ? T : number;

type Three = SelfOrNumber<3>; // type Three = 3
type Num = SelfOrNumber<'not a number'>; // type Num = number

TypeScript Playground

So I'm not sure why it's not working in my example. Here's a minimal reproducible example:

function returnSelfRandOrNullConditional<T extends number | string>(arg: T): (T extends number ? T : number) | null {
    if (typeof arg === 'number') {
        if (arg > 0) {
            return arg; // Type 'T & number' is not assignable to type 'T extends number ? T : number'.
        } else {
            return null;
        }
    } else {
        const rand = Math.random();
        if (rand > 0.5) {
            return rand; // Type 'number' is not assignable to type '(T extends number ? T : number) | null'.
        } else {
            return null;
        }
    }
};

TypeScript Playground

I have found that I can get the results I want using an overloaded function, so I know I can use that approach, but it's not clear to me why a conditional type doesn't work the way I had expected it to here.

function returnSelfRandOrNullOverloaded<T extends number>(arg: T): T | null
function returnSelfRandOrNullOverloaded<T extends string>(arg: T): number | null
function returnSelfRandOrNullOverloaded<T extends number | string>(arg: T): number | null
function returnSelfRandOrNullOverloaded<T extends number | string>(arg: T): number | null {
    if (typeof arg === 'number') {
        if (arg > 0) {
            return arg;
        } else {
            return null;
        }
    } else {
        const rand = Math.random();
        if (rand > 0.5) {
            return rand;
        } else {
            return null;
        }
    }
}

const a = returnSelfRandOrNullOverloaded(3); // 3 | null
const b = returnSelfRandOrNullOverloaded(-2); // -2 | null
const c = returnSelfRandOrNullOverloaded('test'); // number | null

let test = Math.random() > 0.5 ? 3 : 'test';
const d = returnSelfRandOrNullOverloaded(test); // number | null

TypeScript Playground

Upvotes: 1

Views: 435

Answers (1)

Brooke Hart
Brooke Hart

Reputation: 4049

Having returned to this a year and a half later, with a better understanding of TypeScript, I think the information I was missing last time is around how TypeScript doesn't narrow generic types.

In my first block, although I had narrowed the type of arg to T & number, TypeScript does not narrow the generic type T at all. As far as it knows, T could still be string, or never, or anything else that meets the constraint T extends number | string.

This means TypeScript can't evaluate the conditional type T extends number ? T : number at that point. And it's possible that the narrowed type T & number wouldn't be able to be assigned to T extends number ? T : number for some given types of T.

For example, if T was string, then the T & number type of arg would resolve to never, but the T extends number ? T : number type that needs to be returned would resolve to number. So, TypeScript correctly tells me that T & number can't be assigned to T extends number ? T : number because it requires that assignment to be allowed for all possible types that T could be.

As far as I know, there's no way to get TypeScript to narrow the generic type of a function like this. The only ways I know around it are:

  1. Sometimes, as in this case, it's possible to resolve by refactoring the function to use overloads.

  2. Reassign an argument to a non-generic type (typically it's useful to use the restraint, i.e. number | string in this case) and narrow that. Then, if it's necessary to re-create any links created through multiple uses of generic types, use type assertions to do that.

In my question, I've aleady given a solution using overloads. And in the case in my question, the solution using a type assertion isn't particularly interesting, since it's necessary to just restate the return type in the type assertion when returning a value.

But it can still be useful to know this type assertion approach can help in similar cases, where there might be links between arguments. For example:

declare enum MyEnum {
    FOO = 'foo',
    BAR = 'bar',
}

type Data = {
    [MyEnum.FOO]: number;
    [MyEnum.BAR]: string;
}

function genericFn<T extends MyEnum>(genArg: T, dataArg: Data[T]): void {
    // No good
    if (genArg === MyEnum.FOO) {
        genArg; // <- T extends MyEnum
        dataArg; // <- Data[T]
    }

    // Works
    const gen: MyEnum = genArg;
    if (gen === MyEnum.FOO) {
        gen; // <- MyEnum.FOO
        const data = dataArg as Data[typeof gen]; // <- number
    }
}

TypeScript Playground

Of course, the danger of type assertions is that they override TypeScript's built-in type determinations. In cases where it is possible to confidently say "I know more than the TypeScript compiler" they can be safe to use, but even in those cases it's worth being aware that they can still come with a maintenance cost because they are manual. For example, if the signature of that example function changed, then it might also be necessary to manually change its internal type assertions as well.

Upvotes: 0

Related Questions