How-to › Maintain libraries across languages

How to accept optional and reordered function arguments#

Match a call's arguments against a pattern of names and types, so one function accepts several shapes without a chain of typeof checks.

Audience
Library maintainer
Level
intermediate
Topic
Choose and build small utilities
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A pattern matcher such as normaSeveral optional arguments, and calls already written in several shapesA small dependency, and a pattern language to learn and documentThe function has two arguments and one is optional
A single options objectA new API, where you can choose the calling convention outrightA breaking change for existing callers, and a heavier call for the short caseYou cannot change how existing code calls the function
Hand-written typeof checksOne or two shapes, where the branches fit on a screenGrowth with every optional argument, since each one doubles the branchesA third optional argument arrives
TypeScript overloadsYou ship types and want the editor to show each shape separatelyTypes only, so the runtime implementation still has to sort the arguments outYour 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

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. typeof null answers "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

  2. 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

  3. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.