Home > Software design >  TypeScript type declaration accepts both A and B but not "A|B"
TypeScript type declaration accepts both A and B but not "A|B"

Time:11-16

TypeScript experts!

I'm writing a flexible TypeScript function which accepts both type of arguments: class A and B. A and B are independent and not an inherited. However, I need to deny the ambiguous usage of the function called with the union type A | B. Is there any way to declare this in TypeScript? Note: The function must be a very single function but not separated functions for each usages.

class A {/* snip */}
class B {/* snip */}

let a:A = new A();
fn(a); // definitive usage is OK

let b:B = new B();
fn(b); // definitive usage is OK

let c:(A|B) = somecondition ? new A() : new B();
fn(c); // ambiguous usage should throw TypeScript compilation error here.

Edited: Thank you for answers! I'm sorry but the case above was not the exact correct case. I need a function which accepts a collection of multiple arguments with a rest parameter as below:

class A {/* snip */}
class B {/* snip */}

let a:A = new A();
let b:B = new B();
let c:(A|B) = somecondition ? new A() : new B();

interface Fn {
    (...args: V[]) => void;
}

const fn: Fn = (...args) => {/* snip */};

fn(a); // single definitive usage is OK
fn(b); // single definitive usage is OK
fn(a, b, a, b); // multiple definitive usage is still OK
fn(c); // ambiguous usage should throw TypeScript compilation error here.

CodePudding user response:

A and B are currently structurally identical because they are just empty classes. Your real classes probably have something in them. But currently the type system would not be able to differentiate between them.

To have a function described in your question, we also need to make them different here.

class A {
  a!: string
}
class B {
  b!: string
}

The overload approach in your question is actually the right solution. Just remove the overload containing the union.

interface Fn {
    (arg: A): any;
    (arg: B): any;
}

Declaring a function with this type and calling it will result in the following behavior:

let fn: Fn = () => {}

let a:A = new A();
fn(a); // ok

let b:B = new B();
fn(b); // ok

let c:(A|B) = (true as boolean) ? new A() : new B();
fn(c); // No overload matches this call.

Playground


That edit definitely complicates things :/

Let's fill our classes again.

class A {
  a!: number
}
class B {
  b!: string
}

Now to the function itself:

type IsUnion<T, U extends T = T> = 
  T extends unknown ? [U] extends [T] ? {} : never : {};

type ValidateTuple<T extends any[]> = T & {
  [K in keyof T]: IsUnion<T[K]>
}

interface Fn {
    <T extends any[]>(...args: ValidateTuple<[...T]>): void;
}

const fn: Fn = (...args) => {};

We can map over the elements in the tuple T and check if each element is a union. If it is a union, we intersect it with never leading to the compilation error.

fn(a); // ok
fn(b); // ok
fn(a, b, a, b); // ok

fn(c); // Error

Playground

CodePudding user response:

You cannot do that.

JS doesn't support function overloading. TS offers this, but at the implementation level you end up with just one function.

You type for the first parameter is actually gonna be A | B. So there is no way to allow for A and B, but not allow A | B.

  • Related