Parameters and arguments
A call maps arguments onto parameters by position. That is the whole mechanism, and everything else on this page is a way to work around it.
JavaScript is entirely permissive about the count: extra arguments are dropped, missing parameters become undefined, and nothing complains. TypeScript complains. That single difference is the most useful thing in this entry, because it changes what the language's parameter features are for: arguments, manual arity checks, and much of the defensive code you will read in older JavaScript existed to compensate for a check the checker now does. What remains genuinely useful: defaults, rest parameters, spreading, and the object-literal trick that stands in for named parameters.
Create programs/parameters-and-arguments.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
the runtime never counts; the checker always does
Call a two-parameter function with three arguments, then with one:
Deno.test("the runtime never counts; the checker always does", () => {
function pair(x: string, y: string): [string, string] {
return [x, y];
}
assertEquals(pair("a", "b", "c"), ["a", "b"]);
assertEquals(pair("a"), ["a", undefined]);
});
Check programs/parameters-and-arguments.test.ts
TS2554 [ERROR]: Expected 2 arguments, but got 3.
assertEquals(pair("a", "b", "c"), ["a", "b"]);
~~~
at file:///programs/parameters-and-arguments.test.ts:9:33
TS2554 [ERROR]: Expected 2 arguments, but got 1.
assertEquals(pair("a"), ["a", undefined]);
~~~~
at file:///programs/parameters-and-arguments.test.ts:11:18
An argument for 'y' was not provided.
function pair(x: string, y: string): [string, string] {
~~~~~~~~~
at file:///programs/parameters-and-arguments.test.ts:5:30
Found 2 errors.
error: Type checking failed.
TS2554, twice, exactly as blunt as it should be. Pin both to watch the runtime shrug:
Deno.test("the runtime never counts; the checker always does", () => {
function pair(x: string, y: string): [string, string] {
return [x, y];
}
// @ts-expect-error: Expected 2 arguments, but got 3.
assertEquals(pair("a", "b", "c"), ["a", "b"]);
// @ts-expect-error: Expected 2 arguments, but got 1.
assertEquals(pair("a"), ["a", undefined]);
});
Check programs/parameters-and-arguments.test.ts
running 1 test from ./programs/parameters-and-arguments.test.ts
the runtime never counts; the checker always does ... ok (0ms)
ok | 1 passed | 0 failed (1ms)
Both lines run, the pattern the what a type is page pinned in a pinned type error runs anyway. The first drops "c". The second is the one worth pausing on: pair("a") gives y the value undefined even though its type says string, which is a lie the runtime is happy to tell, and without the checker the failure would surface later and somewhere else. This is the plainest example in the whole series of a type annotation catching a bug the language was designed not to notice.
defaults fill in for undefined, not for null
A parameter default fills in for a missing argument. Predict what it does with null:
Deno.test("defaults fill in for undefined, not for null", () => {
function withDefault(
x: number | undefined,
y = 0,
): [number | undefined, number] {
return [x, y];
}
assertEquals(withDefault(1), [1, 0]);
assertEquals(withDefault(undefined, undefined), [undefined, 0]);
assertEquals(withDefault(1, null as unknown as number) as unknown, [
1,
0,
]);
});
Check programs/parameters-and-arguments.test.ts
running 2 tests from ./programs/parameters-and-arguments.test.ts
...
defaults fill in for undefined, not for null ... FAILED (8ms)
ERRORS
defaults fill in for undefined, not for null => ./programs/parameters-and-arguments.test.ts:16:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
[
1,
- null,
+ 0,
]
FAILURES
defaults fill in for undefined, not for null => ./programs/parameters-and-arguments.test.ts:16:11
FAILED | 1 passed | 1 failed (9ms)
error: Test failed
The null came through untouched. undefined counts as missing, which is why passing it explicitly is the same as passing nothing, and that is what makes forwarding an optional value through a call work. null is a value someone chose, and the default mechanism respects the choice, which is the same line ?? draws on the nothing, twice page in ?? treats only null and undefined as missing. Correct the prediction to null.
The casts on that line deserve their own sentence. null as unknown as number is the double claim from the any, unknown, and never page's as is a claim, not a conversion, needed because y's type honestly refuses null, and the as unknown on the result exists because the lie propagates: the return type still says number while the value holds null, so even the assertion about the lie needs a loosened type to compile.
defaults fill in for undefined, not for null ... ok (0ms)
a rest parameter is always an array
Deno.test("a rest parameter is always an array", () => {
function collect(first: string, ...rest: string[]): [string, string[]] {
return [first, rest];
}
assertEquals(collect("a", "b", "c"), ["a", ["b", "c"]]);
// @ts-expect-error: Expected at least 1 arguments, but got 0.
assertEquals(collect(), [undefined, []]);
});
a rest parameter is always an array ... ok (0ms)
...rest gathers whatever is left into a real array, and with nothing left it is an empty array rather than undefined, so the body never has to check. Note the pinned code changed: the presence of a rest parameter turns the arity complaint into TS2555, "Expected at least 1 arguments", because the upper bound is gone.
a rest parameter has to be last
Deno.test("a rest parameter has to be last", () => {
const error = assertThrows(
() => new Function("...x", "...y", ""),
SyntaxError,
);
assertStrictEquals(
error.message,
"Rest parameter must be last formal parameter",
);
});
a rest parameter has to be last ... ok (0ms)
There can be only one, and it goes at the end, and both restrictions produce the same SyntaxError. Observing it takes new Function, the trick from the functions page's a function built at run time, because a file containing the mistake simply does not parse; this is the same move the branching page used to watch a duplicate declaration refuse to compile. The reason is the mechanism: ...rest means "everything remaining", so a parameter after it could never receive anything, and a second one would have nothing left to gather.
named parameters are an object, plus = {}
Deno.test("named parameters are an object, plus = {}", () => {
function paginate({ page = 1, size = 20, sort = "id" } = {}) {
return { page, size, sort };
}
assertEquals(paginate(), { page: 1, size: 20, sort: "id" });
assertEquals(paginate({ size: 5 }), { page: 1, size: 5, sort: "id" });
function paginateStrict(
{ page = 1, size = 20, sort = "id" }: {
page?: number;
size?: number;
sort?: string;
},
) {
return { page, size, sort };
}
assertThrows(
() => {
// @ts-expect-error: Expected 1 arguments, but got 0.
paginateStrict();
},
TypeError,
"Cannot read properties of undefined (reading 'page')",
);
});
named parameters are an object, plus = {} ... ok (0ms)
JavaScript has no named parameters, and paginate is the standard simulation: one object parameter, destructured in the head (the pattern itself gets a full treatment on the destructuring page), with a default for each property. The gain is real: arguments carry labels, their order stops mattering, and a caller supplies any subset without filling earlier slots with undefined.
paginateStrict is the same function with the trailing = {} left off, which is the part people forget. Without it, the no-argument call hands the pattern undefined to destructure, and reaching into it is the pinned TypeError: the defaults inside the pattern only apply once there is an object to destructure, and the = {} is what provides that object. The checker catches this one too, as the pinned TS2554, so in a checked codebase it is a compile error; the runtime message stays worth knowing because it is what a stack trace from untyped code will show you.
length is the arity a caller must supply
Deno.test("length is the arity a caller must supply", () => {
function pair(x: string, y: string): [string, string] {
return [x, y];
}
function collect(first: string, ...rest: string[]): [string, string[]] {
return [first, rest];
}
function withDefault(
x: number | undefined,
y = 0,
): [number | undefined, number] {
return [x, y];
}
assertStrictEquals(pair.length, 2);
assertStrictEquals(collect.length, 1);
assertStrictEquals(withDefault.length, 1);
});
length is the arity a caller must supply ... ok (0ms)
A function's .length is not the number of names in the head. It counts parameters before the first one with a default and excludes the rest parameter, which makes it the arity a caller is obliged to supply. It occasionally matters when a library inspects your callback to decide how to call it.
optional and defaulted are different promises
y?: number and y = 0 both let the caller omit y. Predict optional.length:
Deno.test("optional and defaulted are different promises", () => {
function optional(x: number, y?: number): [number, number | undefined] {
return [x, y];
}
function withDefault(x: number, y = 0): [number, number] {
return [x, y];
}
assertEquals(optional(1), [1, undefined]);
assertEquals(withDefault(1), [1, 0]);
assertStrictEquals(optional.length, 1);
assertStrictEquals(withDefault.length, 1);
});
Check programs/parameters-and-arguments.test.ts
running 7 tests from ./programs/parameters-and-arguments.test.ts
...
optional and defaulted are different promises ... FAILED (8ms)
ERRORS
optional and defaulted are different promises => ./programs/parameters-and-arguments.test.ts:101:11
error: AssertionError: Values are not strictly equal.
[Diff] Actual / Expected
- 2
+ 1
FAILURES
optional and defaulted are different promises => ./programs/parameters-and-arguments.test.ts:101:11
FAILED | 6 passed | 1 failed (11ms)
error: Test failed
optional.length is 2. The ? is a type-level annotation that erases completely, so the emitted JavaScript is function optional(x, y) and both names count. A default is real code that survives compilation, which is why withDefault's arity drops to one. A TypeScript-only feature cannot change a JavaScript-observable number, the rule from a pinned type error runs anyway seen from the other side, and this is the cleanest place to watch it work. Correct the prediction to 2:
optional and defaulted are different promises ... ok (0ms)
The promises differ in the body too. y?: number says the argument may be absent and the body then has to handle undefined. y = 0 says the argument may be absent and the body will never see that. Prefer the default when a sensible one exists, because it removes a branch from every line of the body.
spreading is the mirror of a rest parameter
Deno.test("spreading is the mirror of a rest parameter", () => {
function total(a: number, b: number, c: number): number {
return a + b + c;
}
assertStrictEquals(Math.max(...[-1, 5, 11, 3]), 11);
assertStrictEquals(Math.max(-1, ...[-5, 11], 3), 11);
const items = ["a", "b"];
items.push(...["c", "d"]);
assertEquals(items, ["a", "b", "c", "d"]);
const three: [number, number, number] = [1, 2, 3];
assertStrictEquals(total(...three), 6);
});
spreading is the mirror of a rest parameter ... ok (0ms)
Same three dots, opposite direction. A rest parameter sits in a definition and collects arguments into an array; a spread sits at a call site and expands an iterable into arguments, and it is the protocol from iterables and iterators doing the work, not arrays specifically. Spreading is how you reach the variadic functions that predate it: Math.max takes any number of arguments and no array, push appends any number of values and has no append-an-array counterpart, and a spread supplies both. The second line shows it composing with ordinary arguments, and the last line passes a tuple, whose known length matters in the next step.
a spread into fixed parameters needs a tuple
Deno.test("a spread into fixed parameters needs a tuple", () => {
function total(a: number, b: number, c: number): number {
return a + b + c;
}
const unknownLength: number[] = [1, 2, 3];
// @ts-expect-error: A spread argument must either have a tuple type or be passed to a rest parameter.
assertStrictEquals(total(...unknownLength), 6);
});
a spread into fixed parameters needs a tuple ... ok (0ms)
TS2556, pinned, and it is the arity check from the first step one level deeper: a number[] has no statically known length, so the checker cannot tell whether it supplies the three arguments total requires, while the tuple in the previous step could prove it. The fix is one of the two things the message names: give the value a tuple type, or make the receiving function take a rest parameter. A small friction with a real payoff, since the alternative is a call that silently passes two arguments where three were needed.
arguments, and why you can forget it
Deno.test("arguments, and why you can forget it", () => {
function counted(): number {
return arguments.length;
}
assertStrictEquals((counted as (...args: unknown[]) => number)(1, 2, 3), 3);
const replacement = (...args: unknown[]): number => args.length;
assertStrictEquals(replacement(1, 2, 3), 3);
});
arguments, and why you can forget it ... ok (0ms)
Every ordinary function has an implicit arguments object holding everything it was called with, which is how counted reports three arguments its signature never mentions. Arrow functions do not have one, in the same way and for the same reason they have no this of their own, from the value of this. It was the only way to write a variadic function before rest parameters existed, and it is worth knowing only to read older code: it is array-like rather than an array, it appears in no signature so nothing can check it, and the cast needed to even call counted with arguments says the rest of the story. The replacement below it does the same job with a real array, a name, and a checkable signature.
one options object once you have two optionals
Deno.test("one options object once you have two optionals", () => {
type Options = { unique?: boolean; pageSize?: number };
function retrieve(
keyword: string,
{ unique = false, pageSize = 10 }: Options = {},
): string {
return `${keyword}:${unique}:${pageSize}`;
}
assertStrictEquals(retrieve("cats"), "cats:false:10");
assertStrictEquals(retrieve("cats", { pageSize: 25 }), "cats:false:25");
});
one options object once you have two optionals ... ok (0ms)
The shape to copy. Required arguments stay positional, optional ones move into the object, the object itself gets the = {} default, and the type gets a name. Callers pass any subset and never have to remember the order. Below two optional parameters, positional is clearer.
In practice
- Let the checker count arguments for you instead of checking the length of a rest parameter at runtime.
- Prefer a default to an optional parameter when a sensible default exists.
- Prefer an optional parameter to
undefinedin a union so the call site reads better. - Use one options object once you have two optional parameters, and give it a default of
= {}. - Use
f(...args)instead off.apply(undefined, args)when no receiver is involved.