Reputation: 67360
I was reading the changes in typescript 4.0, and it has a section called Labeled Tuple Elements. My question isn't about this feature, I get it and I get its value. My question is about the example they used:
function foo(...args: [string, number]): void { // ... }
…should appear no different from the following function…
function foo(arg0: string, arg1: number): void { // ... }
The question is, what is the point of defining a function that first way? As far as I can tell, the second way is objectively better: The same signature, easier to access elements inside the body, self-documenting (the parameters have names), etc. What am I missing? What value does the first example have?
The only advantage I can think of for the top, is that you can refer to args
. That might be useful if you just plan to pass this off to another function? Is that why you can do this; for that scenario?
I get that this can be looked at as opinion based, but the answers I'm looking for aren't: Is there anything else you can do with this syntax that I'm not understanding besides what I've mentioned?
Upvotes: 4
Views: 3521
Reputation: 328292
The rest tuple syntax lets you abstract over function parameter lists in a way that a regular call signature does not. Abstraction is rarely useful if you're only going to use it once. Imagine if you are asked "what's the point of functions in JavaScript?" and being given this example:
// why do this:
const val = ((x: number) => x * x)(5);
// when the following is simpler:
const val = 5 * 5;
Surely, in isolation, there is no reason to create a function like x => x * x
, call it once, and then throw it away. The direct multiplication is obviously superior. The problem here is not the functionality, though, but the example.
So let's look at rest tuples and some of the different things they can be used for once you're allowed to abstract over parameter lists:
Let's say you have a set of functions that all take the same list of parameters, but do not have the same return type:
declare function foo(x: string, y: number, z: boolean): string;
declare function bar(x: string, y: number, z: boolean): number;
declare function baz(x: string, y: number, z: boolean): boolean;
// ... and maybe more
Assuming this is not merely coincidence, and the functions are related in some way, you might want to change the list of parameters at some point and would prefer to do it once and not once for each function. With rest tuples you can do this:
type Params = [x: string, y: number, z: boolean];
declare function foo(...args: Params): string;
declare function bar(...args: Params): number;
declare function baz(...args: Params): boolean;
which is equivalent. If I want to, say, add a fourth parameter, I can do it in Params
.
Let's say I have related functions which have some parameters :
declare function qux(x: string, y: number, z: boolean): number
declare function quux(w: Date, x: string, y: number, z: boolean): string;
Possibly because quux()
's implementation calls qux()
. You can rewrite quux()
's signature to use a rest tuple instead:
declare function quux(w: Date, ...rest: Parameters<typeof qux>): string;
I'm using the Parameters
utility type to extract the tuple type of parameters from the qux
function. If qux()
's parameters change, then quux()
's parameters will change automatically.
Imagine we have the following overloaded frob()
function:
function frob(k: "number", val: number): void;
function frob(k: "string", val: string): void;
function frob(k: "number" | "string", val: number | string) {
if (k === "number") {
console.log(val.toFixed(1)); // error, oops
} else {
console.log(val.toUpperCase()); // error, oops
}
}
As a caller, you can call frob("number", 123)
or frob("string", "hello")
but not frob("number", "hello")
for instance. The implementation of frob()
is not so great, though, because the compiler really doesn't understand that k === "number"
can only happen when val
is a number
. The parameters k
and val
in the implementation are just uncorrelated unions. So you get some errors. This is, of course, easy enough to work around with type assertions.
But consider how much more expressive the following version of frob()
is:
function frob(...args: [k: "number", val: number] | [k: "string", val: string]) {
if (args[0] === "number") {
console.log(args[1].toFixed(1)); // okay
} else {
console.log(args[1].toUpperCase()); // okay
}
}
From the caller's point of view, this is pretty much the same as before. Unions of rest tuples end up acting like multiple call signatures. But the implementation is different. The compiler now understands that the args
rest argument is a discriminated union; if the first element is "number"
, then the second element will definitely be a number
, and the compiler knows it. So it type checks.
Consider the following function withConsoleMessage()
. It takes a function as input, and returns another function which takes a message string
argument first, and then all the arguments of the input function. When you call that function, it logs the message and calls the original function:
function withConsoleMessage<A extends any[], R>(
cb: (...a: A) => R
): (msg: string, ...a: A) => R {
return (msg, ...a) => (console.log(msg), cb(...a));
}
const atan2WithMessage = withConsoleMessage(Math.atan2);
const ret = atan2WithMessage("hello!", 4, 4); // "hello!"
console.log(ret * 4); // 3.141592653589793
Look at that; the compiler knows that atan2WithMessage()
takes a string
and then two number
s. Without tuple-type rest elements, it would be difficult to begin to give withConsoleMessage()
a type signature. I suppose you could give it a bunch of overloads so it accepted callbacks of different arities, up to some arbitrary limit:
function withConsoleMessage<R>(cb: () => R): (msg: string) => R;
function withConsoleMessage<R, A>(cb: (a: A) => R): (msg: string, a: A) => R;
function withConsoleMessage<R, A, B>(cb: (a: A, b: B) => R): (msg: string, a: A, b: B) => R;
function withConsoleMessage<R, A, B, C>(cb: (a: A, b: B, c: C) => R): (msg: string, a: A, b: B, c: C) => R;
function withConsoleMessage(cb: Function) {
return (msg: string, ...a: any[]) => (console.log(msg), cb(...a));
}
That would sort of work, and before TypeScript 3.0, this is what you had to do if you wanted to approximate this behavior. It was ugly and brittle compared to a generic rest tuple.
I think I'll stop there for now; I don't know how much more time I want to spend on an answer to a question that's already accepted a different answer, after all! Suffice it to say that abstractions like rest tuples open up many possibilities.
Upvotes: 5
Reputation: 35512
I think that one important thing to understand is that typescript's typing is just a type layer for static checking that compiles down into javascript completely unaffected by the typesystem. If everything perfectly adhered to the types they were given, then yes, ...args: [string, number]
would be essentially equivalent to arg0: string, arg1: number
except for the fact that it puts the arguments in an array.
However, if the types aren't adhered to (maybe due to incorrect casts or calling a typescript library from javascript), then there is a subtle difference in the two in that the first one will handle any number of arguments and the array will be the appropriate length whereas the second will ignore any arguments past the second and fill in unspecified arguments with undefined
.
Another even subtler difference is that rest arguments don't count towards Function.prototype.length
, which means your first function will have a length of 0 whereas the second correctly reports a length of 2.
Will these differences realistically matter? Probably not. Is there any reason to use the first? Unless you really want an array of the two arguments for whatever reason, probably not. The second is more readable, and allows you to easily add default argument values. Why does typescript allow the first? Because it should be a superset of javascript, and javascript has rest arguments, so typescript should supply a way to type it, even if of finite length.
Upvotes: 4