Your library function takes a URL, an options object, a limit and a callback, and three of the four
are optional. The first forty lines of the function are typeof checks shuffling arguments into
place. A new optional argument arrives and the shuffling has to be rewritten, because every branch
assumed the previous argument count.
What you get
You will end up with one line describing the argument shapes and a matcher that fills the names. The errors then say which argument was wrong, rather than failing later on a property access. This is for you if you maintain a library whose calls come in several shapes.
Short answer
Describe the arguments as a pattern of names and types, and match the call against it left to right. An argument that does not fit the next slot is tried against the one after, so an optional argument can be left out from the middle. A required slot that nothing fills raises an error naming the slot and what it expected.
You will need
Node 22 or later, and a function whose callers pass arguments in more than one shape. The matching
rests on distinguishing the runtime types properly, which means treating null and an array as
their own kinds rather than as
objects.1
Array.isArray
is the check for the second.
Voxgig maintains norma. This page compares that idea with overloads, an options object, and the checks you would otherwise write by hand.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A pattern matcher such as norma | Several optional arguments, and calls already written in several shapes | A small dependency, and a pattern language to learn and document | The function has two arguments and one is optional |
| A single options object | A new API, where you can choose the calling convention outright | A breaking change for existing callers, and a heavier call for the short case | You cannot change how existing code calls the function |
| Hand-written typeof checks | One or two shapes, where the branches fit on a screen | Growth with every optional argument, since each one doubles the branches | A third optional argument arrives |
| TypeScript overloads | You ship types and want the editor to show each shape separately | Types only, so the runtime implementation still has to sort the arguments out | Your callers are untyped, which is most published libraries |
The options object is what to reach for in a function you are designing today, and it is not available for one that has shipped. A published function with four positional arguments has callers you cannot see, and changing the convention breaks all of them.
Overloads and a matcher solve different halves. Overloads tell the compiler and the editor what shapes exist; the matcher is what the code does at runtime when a caller ignores both. Shipping both is common, and only one of them is enforcement.2
Describe the shapes in one line
The pattern carries the names, the types, the optionality, and the defaults.
export function listMeters(...args) {
return normalize('url:string options:object? limit:number=50 done:function?', args)
}
Reading it left to right is what makes an omitted middle argument work. A number in the second
position does not fit options:object?, so the matcher moves on and tries limit:number, and the
caller gets what they meant.
Keep the pattern beside the function rather than in a shared table. It is documentation as much as code, and a reader of the function should not have to look elsewhere to learn what it takes. A pattern in a shared constants file is a signature nobody can see from the call site.
Refuse what does not fit
An argument nothing can hold is a caller mistake, and saying so early is the point.
if (at < args.length) throw new TypeError(`unexpected extra argument of type ${typeOf(args[at])}`)
return out
Without that line, a typo such as passing a string where a function belongs is silently dropped, and the failure surfaces much later as a callback that never fires.
Name the slot in the message. url: expected string, got object sends a caller to the right
argument, while a failure on a property access inside your function sends them into your source.
Check it worked
Seven calls: five of them valid, and two of them mistakes.
node demo.mjs
("https://api.example.com") url="https://api.example.com" limit=50
("https://api.example.com", {"retry":true}) url="https://api.example.com" options={"retry":true} limit=50
("https://api.example.com", 100) url="https://api.example.com" limit=100
("https://api.example.com", {"retry":true}, 100) url="https://api.example.com" options={"retry":true} limit=100
("https://api.example.com", fn) url="https://api.example.com" limit=50 done=fn
({"retry":true}) TypeError: url: expected string, got object
("https://api.example.com", "extra") TypeError: unexpected extra argument of type string
Rows three and five are the ones a chain of checks gets wrong. A number in second position skips the options slot, and a function in second position skips both options and limit, and in each case the default still applies. Every valid call ends with the same named arguments regardless of how many were passed.
The last two rows fail at the call rather than somewhere inside. That is worth as much as the flexibility: a library that accepts anything and fails later is harder to use than one that says no.
The pattern is also the shortest documentation of the signature anyone will write. From its four names, four types, and one default, a reader knows every shape the function takes without reading the body.
node --test normalize.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 117.671513
When it goes wrong
Two optional slots share a type. The matcher fills the first and the caller meant the second, and it cannot know. Give one of them a distinct type, or move it into the options object. Two optional strings in a row is the case that cannot be rescued by any matcher.
null fills an object slot. The type check used typeof, which reports object for null. Treat
null as its own type, which the sample does.
An array fills an object slot. Same cause, same fix. Arrays are objects to typeof and are never
what an options slot means. A list of ids passed where options belong is the call that finds this.
The pattern and the documentation disagree. Two descriptions of one signature. Generate the documented signature from the pattern, or at least test that the examples in the documentation still match it.
When not to do this
Do not use a matcher on a function with two arguments. A single if is clearer than a pattern, and
a dependency for that is not worth explaining to the next maintainer.
Do not use it to avoid designing a signature. Accepting six shapes because callers asked for each of them produces a function nobody can describe, and every shape is a promise you now maintain forever.
Do not reach for norma, or any matcher, on a new API. Take the options object while you still can, and keep the matcher for functions whose calling convention is already in the wild.3
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
typeof nullanswers"object"for a reason MDN sets out with some care. The first JavaScript engine stored each value as a type tag and a value. The tag for objects was 0, and null was the null pointer, which is also 0. A fix was proposed for ECMAScript, as an opt-in, and rejected. The behavior stands since the beginning of the language, as the page puts it, and it is at least dependable. ↩︎ Back to text -
The TypeScript handbook documents overloads and then advises against them. Its worked example rewrites two overloads as one signature with a union type and calls the result much better. It then closes with a rule: always prefer parameters with union types instead of overloads when possible. The feature is fully specified, fully supported, and recommended against by its own manual, which is a position a manual can hold and a compiler cannot. ↩︎ Back to text
-
norma has been at this since February 2014, when its first version reached npm, and sixteen versions have followed. The registry describes it as a function argument organizer, and its README opens by observing that optional arguments make an API nicer. The README says nothing about what they cost the maintainer, which is the subject of this section. ↩︎ Back to text