JJWesterkamp
JJWesterkamp

Reputation: 7916

Narrowing a type argument that is constrained to a union type in function bodies

I am looking for specific behaviour with regards to union type narrowing. I have the following union type of 'orderable' types:

type Orderable = string | number

I am using this type as a constraint on type arguments to functions, as follows:

function foo<T extends Orderable>(x: T, y: T): void {
    // ...
}

// x and y must be of the same type within T, 
// and this constraint works from the usage perspective:
foo('bar', 'baz') // OK
foo(21, 42)       // OK
foo('bar', 42)    // ERROR

I am not sure if what I want is at all possible in Typescript, but I'd like to be able to use type narrowing within the function applied to the instance of T, rather than on the variables (x and y) whose types are constrained to T. To show you what I mean, here's what I would like to be able to do:

function foo<T extends string | number>(x: T, y: T): void {
    if (typeof x === 'string') {
        // if x = string, then T = string, 
        // thus y = string, and can be assigned to z:
        const z: string = y
    }
}

But the above assignment to z gives the error

Type 'T' is not assignable to type 'string'.
- Type 'string | number' is not assignable to type 'string'.
- - Type 'number' is not assignable to type 'string'.

So in other words, the if statement should 'prove' that T = string, but it only proves that x extends string. Although it does make some sense that proving x is a string proves nothing about the type of y, I still think it is weird considering the usage examples where foo('bar', 42) threw an error; Both the type-checking that results in that error and the type-checking within the function body rely on the same function signature of foo. How can this (to me unexpected) behaviour be properly explained? Is there another way to do this sort of narrowing?

Thanks in advance!

Upvotes: 1

Views: 207

Answers (1)

Artyom Kozhemiakin
Artyom Kozhemiakin

Reputation: 1462

The problem is you may call this function like this:

foo<string | number>("bar", 5);

It means that your T is either number or string, and final type of the first argument is independent from the final type of the second one, they just must be a sub-types of string | number.

However you may be more strict:

function areStrings(v: [string, string] | [number, number]): v is [string, string] {
    return typeof v[0] === 'string';
}

function bar(...args: [string, string] | [number, number]) {
    if (areStrings(args)) {
        const a: string = args[0];
        const b: string = args[1];
    } else {
        const a: number = args[0];
        const b: number = args[1];
    }
}

That way you are forced to provide either two strings or two number, but not a mix of them.

Unfortunately, I do not see any way to eliminate custom type guard here, seems that typescript is not clever enough (yet) to narrow tuple type by its own. But this type guard looks perfectly reasonable for me.

Upvotes: 4

Related Questions