1

I want to have a function that takes two union types as parameters but force them to be the same subtype... without having to check the types myself before calling the function.

This is what I have tried:

The backstory

For simplicity I will define some types:

type Foo = number;
type Bar = [number, number?];
type FooBar = Foo | Bar;

I have a function:

function fn(x: FooBar, y: FooBar) {}

I want to force that x and y be of the same type. IE allow fn(Foo, Foo) and fn(Bar, Bar) but not allow fn(Foo, Bar) and fn(Bar, Foo). So I decided to overload the function.

function fn(x: Foo, y: Foo): void;
function fn(x: Bar, Bar): void;
function fn(x: FooBar, y: FooBar): void {}

I request data from an API. I get two objects from the API, I doubt it matters but they will both be of the same type, either both Foo or both Bar. It depends on what I am asking for.

The problem

When I call my function it complains (I'm using JSON.parse as a way of simulating the API call):

const x: FooBar = JSON.parse("1");
const y: FooBar = JSON.parse("2");
fn(x, y);

Argument of type 'FooBar' is not assignable to parameter of type '[number, number?]'. Type 'number' is not assignable to type '[number, number?]'.

If I check the types before hand it works:

(side note: I wish there was an easier way to check the type when using declared types)

if(typeof x === 'number' && typeof y === 'number') fn(x, y);
else if(Array.isArray(x) && Array.isArray(y)) fn(x, y);

but this seems silly, I don't want to have to check the types before I call the function, when in the end the call will be the exact same.

Somewhat surprisingly using or doesn't work (even though I still don't want to have to do this)

if((typeof x === 'number' && typeof y === 'number') ||
  (Array.isArray(x) && Array.isArray(y))) {
    fn(x, y); // same error as above
}

StackBlitz example

Is there anyway to do what I want? Maybe using a different pattern or approach.

0

2 Answers 2

5

You have to do some kind of type checking before you call fn(x, y), since all the compiler knows is that x and y are both type foobar, and you have declared fn such that it only accepts two foo parameters or two bar parameters, and not both. You know that x and y will both be number (a.k.a. foo), but that's because you've done the work of JSON.parse() in your head and concluded that the types will be okay. The compiler isn't that clever. For all it knows, x could be a foo and y could be a bar, and then fn(x, y) should not work. That is: the error on fn(x,y) without type checking is a good error that helps you write better code.

Thus the most straightforward way to call the overloaded fn on x and y is the type checking you did here:

if (typeof x === 'number' && typeof y === 'number') fn(x, y);
else if (Array.isArray(x) && Array.isArray(y)) fn(x, y);

Yes, it's redundant. Trying to collapse those into a single statement:

if ((typeof x === 'number' && typeof y === 'number') ||
  (Array.isArray(x) && Array.isArray(y))) {
  fn(x, y); // error 😠
}

doesn't work, as you've seen. Dealing properly with call signatures of union types is an outstanding issue. In particular, the types of x and y are correlated subtypes of foobar (meaning they are either both foo or both bar), but the compiler is not able to handle this. I've run into this numerous times and have even suggested a way of dealing with this (which won't be adopted, poor me), but for now there's no great solution.

The simplest thing you can do here if you don't want redundant runtime code (in exchange for some compile time clutter) is to use a type assertion:

if ((typeof x === 'number' && typeof y === 'number') ||
  (Array.isArray(x) && Array.isArray(y))) {
  (fn as (x: foobar, y: foobar) => void)(x, y); // okay
}

}

Here you're asserting that inside the type-checked block, fn can be safely applied to x and y, even though in the general case it can't be. Note that as an assertion you are giving up the safety the compiler, in exchange for the freedom of being able to just write the code as you want.

Personally I'd probably leave the redundant type checking you did above and move on. It has the advantage of compiler-enforced type safety. But it's up to you. Hope that helps; good luck!

Sign up to request clarification or add additional context in comments.

Comments

1

The issue here is that your fn(foobar,foobar) will still execute for fn(foo,bar) in the StackBlitz example you provided. There is an error reported in the editor, but if you put a console.log in the body of fn, you'll see it prints out. If you pass fn(1, [2, 3]), you'll see that it prints out without issue.

In order to restrict usage to a specific matching here, you could do a type check.

type foo = number;
type bar = [number, number?];
type foobar = foo | bar;

function fn(x: foobar, y: foobar): void {
  if (typeof x != typeof y) return;
  console.log(x + " __ " + y);
}

const x: foobar = JSON.parse("1");
const y: foobar = JSON.parse("2");

// Executes
fn(x, y);
// Doesn't execute
fn(2, [4, 5]);

The difficulty here arises because you are defining a type foobar, which now always matches foo and bar as though they were interchangeable.

1 Comment

Yes I was doing this initially. But then you wont get any compile errors when you call the function with mixed typing. Thats why I decided to overload the method. I realize there might not be a proper solution to what I want but thought maybe there was some creative way of using generics or something so decided to still ask.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.