Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript: Spreading function parameters defined as tuples

Tags:

typescript

I have a Typescript project where I'm calling Date inside of a function. I want to use the same function parameters as the constructor from Date which is overloaded:

interface DateConstructor {
    new(): Date;
    new(value: number | string | Date): Date;
    new(year: number, month: number, date?: number, hours?: number, minutes?: number, seconds?: number, ms?: number): Date;
    // ...
}

I would like to use the spread operator in order to pass the parameters to the constructor of Date. So my function should hopefully look like that:

function myFn(...args: DateComponents): Date {
    return new Date(...args)
}

I'm not actually returning a date in my case, but this is just for testing...

The question is now how to define DateComponents. I successfully implemented the 3 overloaded signatures of DateConstructor separately from each other.

// Case A: No argument provided
interface A {
  (): Date;
}
const a: A = function (...args: []): Date {
  return new Date(...args);
}

// Case B: 1 argument provided
interface B {
  (value: number | string | Date): void;
}
const b: B = function (...args: [number | string | Date]): Date {
  return new Date(...args);
}

// Case C: Between 2 and 7 arguments provided
interface C {
  (year: number, month: number, date?: number, hours?: number, minutes?: number, seconds?: number, ms?: number): void;
}
const c: C = function (...args: [number, number, number?, number?, number?, number?, number?]): Date {
  return new Date(...args);
}

So far so good, everything works.

  • A: An empty tuple is used to cover the case where no argument is passed
  • B: A tuple with one element covers the case where a single number, string or Date object is passed
  • C: A tuple with 2 mandatory and 5 optional number elements covers the cases where 2-7 arguments are passed

If I try to combined these 3 examples into 1, I get this:

interface D {
  (): void;
  (value: number | string | Date): void;
  (year: number, month: number, date?: number, hours?: number, minutes?: number, seconds?: number, ms?: number): void;
}
type DateComponents = [] | [number | string | Date] | [number, number, number?, number?, number?, number?, number?];
const d: D = function (...args: DateComponents): Date {
  return new Date(...args); // <-- ERROR: Expected 0-7 arguments, but got 0 or more.
}

I get an error saying that Typescript thinks that more than 7 arguments could be passed into the Date constructor. I do not understand why because my type DateComponents explicitly defines tuples of 0-7 elements.

I could fix this by using some conditions and type assertions, but I'm hoping there is a prettier solution?

See the code in the Playground here

Any idea to fix this? Thanks a lot!

like image 878
ymonb1291 Avatar asked Dec 30 '22 19:12

ymonb1291


1 Answers

The underlying cause is that TypeScript does not support resolving a call to an overloaded function/constructor with multiple of its call/construct signatures at once. The call new Date(...args); is a single call but in order for it to be accepted, the compiler would have to break DateComponents up into its union members and see that each member is assignable to at least one construct signature. Instead, it sees that no single Date construct signature is applicable for the entire DateComponents union, and gives up.

Note that the specific wording of the error you're seeing is kind of a red herring; the compiler can't accept the input and tries to shoehorn this into the available error message about number of parameters. This has happened before (e.g., microsoft/TypeScript#28010, microsoft/TypeScript#20372), but it hasn't seemed like a big priority to fix.

Anyway, there is a (rather longstanding) open feature request in GitHub asking for overloaded functions to accept unions of parameters; see microsoft/TypeScript#14107. It's not clear that this will ever happen.


So, what can you do? The easiest thing is just to use a type assertion:

const d: D = function (...args: DateComponents): Date {
  return new Date(...args as ConstructorParameters<typeof Date>);
}

I know you want something "prettier", but believe me, anything I can think of to fix it turns out to be uglier.


For example, you can walk the compiler through the different possibilities manually and put a bunch of redundant code in there:

const e: D = function (...args: DateComponents): Date {
  return args.length === 0 ? new Date(...args) :
    args.length === 1 ? new Date(...args) :
      new Date(...args);
}

which is not pretty either.


Or you can try to build some code that converts an overloaded constructor into a non-overloaded one that takes a union type of parameters. That is, manually simulate an implementation of microsoft/TypeScript#14107. If you did this, the call would look like this:

const f: D = function (...args: DateComponents): Date {
  return new (unifyConstructorOverloads(Date))(...args);
}

which is not that ugly itself. But the definition of unifyConstructorOverloads would be like this:

type UnifyConstructorOverloads<T extends new (...args: any) => any> =
  new (...args: ConstructorParameters<ConstructorOverloads<T>[number]>) =>
    InstanceType<ConstructorOverloads<T>[number]>;

const unifyConstructorOverloads = <T extends new (...args: any) => any>(f: T) => f as
  UnifyConstructorOverloads<T>;

which is getting homelier, uses a type assertion, and depends on a definition of ConstructorOverloads<T>, a hypothetical type function that takes an overloaded constructor and separates its multiple construct signatures into a tuple. There's no programmatic way to do this as far as I can tell, so you have to simulate that too up to some number of overloads (say, 5):

type ConstructorOverloads<T> =
  T extends {
    new(...args: infer A1): infer R1; new(...args: infer A2): infer R2;
    new(...args: infer A3): infer R3; new(...args: infer A4): infer R4;
    new(...args: infer A5): infer R5;
  } ? [
    new (...args: A1) => R1, new (...args: A2) => R2,
    new (...args: A3) => R3, new (...args: A4) => R4,
    new (...args: A5) => R5
  ] : T extends {
    new(...args: infer A1): infer R1; new(...args: infer A2): infer R2;
    new(...args: infer A3): infer R3; new(...args: infer A4): infer R4
  } ? [
    new (...args: A1) => R1, new (...args: A2) => R2,
    new (...args: A3) => R3, new (...args: A4) => R4
  ] : T extends {
    new(...args: infer A1): infer R1; new(...args: infer A2): infer R2;
    new(...args: infer A3): infer R3
  } ? [
    new (...args: A1) => R1, new (...args: A2) => R2,
    new (...args: A3) => R3
  ] : T extends {
    new(...args: infer A1): infer R1; new(...args: infer A2): infer R2
  } ? [
    new (...args: A1) => R1, new (...args: A2) => R2
  ] : T extends {
    new(...args: infer A1): infer R1
  } ? [
    new (...args: A1) => R1
  ] : any

which, as far as aesthetics go, is well past "ugly" and probably hovering around "grotesque". If you were going to do this sort of overload-unification thing in many places in your code base, I could imagine shutting away ConstructorOverloads (and an analogous Overloads for regular functions, see this question for that code) into an unlit library, so that you could use it without ever looking directly upon its abominable visage.


But if you're only doing it a few times, I'd strongly suggest using a type assertion and moving on.

Playground link to code

like image 93
jcalz Avatar answered Feb 25 '23 01:02

jcalz