Named arguments in JavaScript

In type theory for named arguments we developed the idea of named arguments as a distinct type whose desired behaviour differs from that of records, which are the usual way of modelling polymorphic objects. We determined that for record types, it’s fine for a caller to pass an object with fields the called function does not use, but for named arguments this should be considered an error. Conversely, a function with named arguments should be able to add new option names without breaking existing callers, but callers should not be allowed to pass unrecognised names. In this article we’ll explore how to realise these semantics in JavaScript, whose object model leans more towards record semantics and doesn’t provide strong support for named arguments.

To get an idea of how to approach this problem, let’s take a look at a language that at one point did not have built-in named arguments, but added them via a series of syntactic tweaks. In Ruby, the built-in key-value map type is called a hash and is written as { key1 => value1, key2 => value2, ... } where the keys and values are arbitrary expressions. Ruby also has a built-in datatype called a symbol, which is just a name written prefixed with the : character. For a long time, hashes were used to implement optional named arguments, by defining a function parameter with a default value of an empty hash.

def url(options = {})
  host, port, path = options.values_at(:host, :port, :path)
  "http://#{ host }:#{ port }#{ path }"
end

With this function definition we can pass in a hash that defines the function’s inputs by name rather than by position.

url({ :host => "example.com", :port => 80, :path => "/" })
# -> "http://example.com:80/"

Ruby adds a couple of syntactic shortcuts designed to make this pattern easier. First, when the keys of a hash are symbols, they can be written as key: rather than as :key =>, giving us:

url({ host: "example.com", port: 80, path: "/" })
# -> "http://example.com:80/"

Second, if the final argument to a function call is a hash, the braces can be omitted, reducing the line noise and making the hash look like a set of named arguments rather than a distinct single argument by itself.

url(host: "example.com", port: 80, path: "/")
# -> "http://example.com:80/"

Despite these niceties, Ruby’s “hashes as named arguments” solution misbehaved in a similar manner to our attempts to implement them in JavaScript. First, the values_at method and the options[key] syntax do not throw an error for missing keys, so if we omit one of the required arguments the function may silently do something unexpected:

url(port: 80, path: "/")
# -> "http://:80/"

This can be remedied by using the fetch method, which raises an exception if you request a key that’s not present.

def url(options = {})
  host, port, path = [:host, :port, :path].map { |key| options.fetch(key) }
  "http://#{ host }:#{ port }#{ path }"
end

url(port: 80, path: "/")
# -> `fetch': key not found: :host (KeyError)

That deals with required arguments, but as we’ve seen, named arguments are more commonly used to supply optional arguments, and in this case we’d like the caller to be able to omit arguments, but get an error if it supplies any unrecognised names. The implementation above will simply ignore any superfluous keys in the options hash, so we do not get an error when doing this:

url(host: "example.com", port: 80, path: "/", scheme: "ws:")
# -> "http://example.com:80/"

This pattern for implementing named arguments frequently results in codebases littered with function arguments that aren’t actually being used, and it’s very hard to detect and remove them. To combat this, Ruby made this pattern into a first-class feature, so that we could designate function parameters as keyword arguments by placing a : after them:

def url(host:, port:, path:)
  "http://#{ host }:#{ port }#{ path }"
end

When the parameters are written in this form, it becomes an error to pass a named argument that the function does not define:

url(host: "example.com", port: 80, path: "/", scheme: "ws:")
# -> `url': unknown keyword: :scheme (ArgumentError)

The other behaviour we want for named arguments is that they can be omitted safely. As written above, all the parameters to url() are required and we get an error for omitting any of them:

url(port: 80, path: "/")
# -> `url': missing keyword: host (ArgumentError)

This is fixed by supplying a default value for any new parameters we add, for example:

def url(host:, port:, path:, scheme: "http:")
  "#{ scheme }//#{ host }:#{ port }#{ path }"
end

url(host: "example.com", port: 80, path: "/")
# -> "http://example.com:80/"

url(host: "example.com", port: 80, path: "/", scheme: "ws:")
# -> "ws://example.com:80/"

We can even make one default value depend on the value assigned to another parameter, for example making the default port value depend on the value of scheme:

DEFAULT_PORTS = {
  "http:" => 80,
  "https:" => 443
}

def url(host:, path:, scheme: "http:", port: DEFAULT_PORTS[scheme])
  "#{ scheme }//#{ host }:#{ port }#{ path }"
end

url(host: "example.com", path: "/")
# -> "http://example.com:80/"

url(host: "example.com", path: "/", scheme: "https:")
# -> "https://example.com:443/"

url(host: "example.com", path: "/", scheme: "https:", port: 9000)
# -> "https://example.com:9000/"

One important feature present in Python and Ruby that didn’t exist for a long time in JavaScript is that these languages make a distinction between the data type of key-value maps (Ruby calls them hashes, Python calls them dictionaries or dicts), and objects in general. In our url() example we might decide the required host and path arguments should actually form an interface implemented by a single object argument, rather than distinct parameters. Here’s how that would look:

def url(request, scheme: "http:", port: DEFAULT_PORTS[scheme])
  "#{ scheme }//#{ request.host }:#{ port }#{ request.path }"
end

The request parameter can now be bound to any object with host and path properties. For example, we might have a Request class with these methods in its API, and we can use url() to serialize it, passing any optional arguments after the Request when calling url():

class Request
  attr_reader :host, :path

  def initialize(host, path)
    @host = host
    @path = path
  end
end

request = Request.new("example.com", "/")

puts url(request)
# -> "http://example.com:80/"

puts url(request, scheme: "https:")
# -> "https://example.com:443/"

puts url(request, scheme: "https:", port: 9000)
# -> "https://example.com:9000/"

But we cannot pass a hash with host and path keys; Ruby will raise an error if we attempt this:

url({ host: "example.com", path: "/" }, port: 3000)
# -> `url': undefined method `host' for
#    {:host=>"example.com", :path=>"/"}:Hash (NoMethodError)

In Ruby, objects and hashes are distinct things. Hashes are just one type of object available in Ruby, and a hash with certain keys is not the same thing as an object with similarly named methods. Indeed, a Ruby hash has a set of methods that are distinct from the keys it contains – the values_at and fetch methods are part of the hash API that we used to access the keys stored inside.

Deciding whether a certain function argument should be an object implementing an interface, or a set of inline named arguments, is part of the art of API design. It is made harder in JavaScript by the fact that historically, JavaScript has conflated these two concepts. It did not have the Map class it does now, and it has object literals, so we can make up ad-hoc objects without needing to define a class to create them first. This means that objects have often been used where a dedicated map type would be a better choice.

But even now we have Map, it might not be a good choice for our use case. We could in theory implement our JavaScript url() function to take its input as a Map:

function url(options) {
  let [host, port, path] = ['host', 'port', 'path'].map((key) => options.get(key))
  return `http://${ host }:${ port }${ path }`
}

url(new Map([['host', 'example.com'], ['port', '80'], ['path', '/']]))
// -> 'http://example.com:80/'

However I doubt that anyone wants to write functions this way – it is far less convenient to define and call functions in this style than it is using object literals. Besides which, the Map type behaves just like a record type with regard to missing and superfluous keys: it is not an error to access a missing key, or to pass a key the function was not expecting. There’s also no nice syntax for destructuring maps and setting default values for missing keys, like there is for objects. It looks like the Map type isn’t what we want, but that should not surprise us; maps are for modelling arbitrary open-ended sets of key-value pairs, not structures where the program expects specific fields to exist.

So back to the drawing board. Our current best idea is to use object literals, with destructuring and default values to specify named optional arguments.

function url({ host, path, port = 80 }) {
  return `http://${ host }:${ port }${ path }`
}

This gets us halfway to what we want: callers are allowed to omit optional fields, but it’s not an error to include unrecognised arguments.

url({ host: 'example.com', path: '/' })
// -> 'http://example.com:80/'

url({ host: 'example.com', path: '/', extra: true })
// -> 'http://example.com:80/'

It is also strictly speaking not an error, in the sense that no exception is raised, if the caller omits a required field:

url({ port: 443, path: '/' })
// -> 'http://undefined:443/'

Sometimes, an attempt to use a field the caller omitted will result in an error. For example if url() did something like calling host.toLowerCase() then we’d get an exception, TypeError: Cannot read property 'toLowerCase' of undefined. But this entirely depends on what the called function happens to do with the arguments, rather than it being an error in calling the function at all, as it is in Ruby when a required parameter is left out. We would rather that this function call be pre-emptively rejected, rather than letting the missing field cause an error deeper inside the system.

Since we’ve been discussing this in terms of types, it’s interesting to observe how TypeScript handles this problem. In TypeScript, we can write the type requirements directly into the function signature:

function url(options: { host: string, port: number, path: string }) {
  let { host, port, path } = options
  return `http://${ host }:${ port }${ path }`
}

Or, we can give this type a name if it becomes cumbersome to keep the type inside the parameter list:

type UrlOptions = {
  host: string;
  port: number;
  path: string;
}

function url(options: UrlOptions) {
  let { host, port, path } = options
  return `http://${ host }:${ port }${ path }`
}

TypeScript will then check that any calls to url() pass an object with the required fields. This call passes the type checker:

url({ host: 'example.com', port: 80, path: '/' })

Omitting a required field gives us a compile-time error and the program will not build:

url({ host: 'example.com', path: '/' })

This produces the following error report:

request.ts:6:5 - error TS2345: Argument of type '{ host: string; path: string; }'
  is not assignable to parameter of type '{ host: string; port: number; path: string; }'.

  Property 'port' is missing in type '{ host: string; path: string; }' but required in
  type '{ host: string; port: number; path: string; }'.

6 url({ host: 'example.com', path: '/' })
      ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  request.ts:1:53
    1 function url(options: { host: string, port: number, path: string }) {
                                            ~~~~
    'port' is declared here.

It tells us the argument type { host: string; path: string; } is not compatible with the parameter type { host: string; port: number; path: string; }, and points out the difference: the port field is missing.

Here TypeScript is enforcing record semantics, where an argument must have all the fields required in the function parameter. This makes sense for most uses of objects in JavaScript, but isn’t what we want for named arguments. Fortunately TypeScript has a way of designating fields as optional by placing a ? after their name, as in port?: number.

function url(options: { host: string, port?: number, path: string }) {
  let { host, port, path } = options
  return `http://${ host }:${ port }${ path }`
}

url({ host: 'example.com', path: '/' })

Note that { x?: T } desugars to { x?: T | undefined }, so if the parameter type is { port?: number } then the following are all valid inputs:

{ port: 80 }
{ port: undefined }
{}

In normal JavaScript, obj.x evaluates to undefined if obj does not have an x property, or if it has such a property but its value is undefined. TypeScript can tell these two cases apart; { x?: T } means it’s okay for field x to be missing, but { x: T | undefined } means the object must have a property named x, but its value may be undefined. For example, the empty object {} is not compatible with the type { port: number | undefined }.

TypeScript’s ? operator gives us a way to indicate it’s okay to omit an optional argument. What about passing unrecognised arguments?

function url(options: { host: string, port?: number, path: string }) {
  let { host, port, path } = options
  return `http://${ host }:${ port }${ path }`
}

url({ host: 'example.com', path: '/', query: { q: 'hello' } })

This gives us a type error:

request.ts:6:39 - error TS2345: Argument of type '{ host: string; path: string;
  query: { q: string; }; }' is not assignable to parameter of type '{ host: string;
  port?: number | undefined; path: string; }'.

  Object literal may only specify known properties, and 'query' does not exist
  in type '{ host: string; port?: number | undefined; path: string; }'.

6 url({ host: 'example.com', path: '/', query: { q: 'hello' } })
                                        ~~~~~~~~~~~~~~~~~~~~~

Now TypeScript is enforcing the named argument semantics we’ve developed here: it is a type error to pass an object with properties the function does not recognise. This is highly useful for addressing our current problem, but wouldn’t this make it impossible to take advantage of polymorphism? What if we have an instance of a class that has host and path among its fields?

const querystring = require('querystring')

type Params = {
  [key: string]: string;
}

class QueryRequest {
  host: string;
  path: string;
  params: Params;

  constructor(host: string, path: string, params: Params) {
    this.host = host
    this.path = path
    this.params = params
  }

  getQueryString() {
    return querystring.encode(this.params)
  }
}

The notation { [key: string]: string } is TypeScript’s way of saying this type has any number of properties with any names, all with type string – it’s how we designate an object being used as a map.

This class has a couple of properties that url() doesn’t know about: params and getQueryString. Can we pass an instance of it to url()?

url(new QueryRequest('example.com', '/', { q: 'hello' }))

It turns out this is fine – when we’re using a class instance instead of an object literal, TypeScript lets the argument have additional fields, so we can still take advantage of polymorphism. The class does still have to have the fields required by the url() function, for example if we remove host from this class:

class QueryRequest {
  path: string;
  params: Params;

  constructor(_host: string, path: string, params: Params) {
    this.path = path
    this.params = params
  }

  getQueryString() {
    return querystring.encode(this.params)
  }
}

Then the url() call above gives a type error:

request.ts:26:5 - error TS2345: Argument of type 'QueryRequest' is not assignable
  to parameter of type '{ host: string; port?: number | undefined; path: string; }'.

  Property 'host' is missing in type 'QueryRequest' but required in type
  '{ host: string; port?: number | undefined; path: string; }'.

26 url(new QueryRequest('example.com', '/', { q: 'hello' }))
       ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  request.ts:1:25
    1 function url(options: { host: string, port?: number, path: string }) {
                              ~~~~
    'host' is declared here.

So TypeScript is mostly following record semantics; all arguments are required to have at least the fields required by the function parameters’ types. For object literals it forbids additional fields, but for class instances it allows them. In fact the distinction is even finer than this. This code produces are type error:

url({ host: 'example.com', path: '/', query: { q: 'hello' } })

While this code compiles successfully:

let options = { host: 'example.com', path: '/', query: { q: 'hello' } }
url(options)

These programs have the same runtime behaviour: an object is constructed using a literal expression and is then passed to the url() function. But TypeScript makes a distinction between an object literal expression, and any other expression that evaluates to an object. In the above example, options in the call url(options) is an expression that evaluates to an object, it is not syntactically an object literal expression.

TypeScript gives object literal expressions a distinct type which we’ll call Exact<T>. Normally with record semantics, a type S is compatible with T if it has at least all the fields T has, but it is allowed to have more. The type Exact<S> is only compatible with a parameter type T if S and T have exactly the same fields. TypeScript doesn’t expose the existence of this Exact<T> wrapper to the user, although there is a request to add it, and it will display the type of the url() argument as simply { host: string; ... } in both the examples above. But internally, it uses this wrapper on object types to give special behaviour to object literals as arguments to functions, precisely because this aligns with the typical use of this syntax to simulate named arguments.

Recall that we discussed how a distinguishing feature of named arguments is that their names are written out inline at every function call, rather than being fields of an object defined once in a class declaration. This makes it more important to catch spelling mistakes in them, as an unrecognised argument is more likely to be a mistake than to be some additional field irrelevant to the current function. When we define a class, that will create many objects that get used for many different functions, all using different subsets of its interface. But when we make an ad-hoc object with a literal, just to pass it to a single function, any fields it has must be relevant to that function otherwise there’s no reason for them to exist. In this example, we might do further work with options after passing it to url():

let options = { host: 'example.com', path: '/', query: { q: 'hello' } }
url(options)
someOtherFunction(options)

It makes sense to let this object have a superset of the fields required by url() and someOtherFunction(). But when an object literal appears directly as a function argument, no other references to it exist, and its only purpose is to provide input to that one function, so it should only have fields needed by that function.

Ultimately TypeScript is striking a balance: it’s trying to retrofit a static type system onto a language that wasn’t designed with one, and that is inherently going to lead to ambiguities or cases it can’t handle. In this case its type system can give us named argument semantics if we use object literals as arguments, but not if we’re constructing options objects in any dynamic way. It’s instructive to look at its behaviour to see what kinds of static guarantees we can put on vanilla JavaScript, and how the behaviour of JS itself forces us to make trade-offs in light of how the language tends to be used.

While I might decide to use TypeScript for an application, my open source libraries are still vanilla JavaScript and need to work with vanilla JavaScript. We can use type systems to help us think through these problems, but we don’t always have them available for writing production code. In my library websocket-driver I make use of named arguments, and have a function that checks for unrecognised fields that looks something like this:

function checkOptions(validKeys, options) {
  for (let key in options) {
    if (!validKeys.includes(key))
      throw new TypeError(`Unrecognised option: ${ key }`)
  }
}

Given a list of acceptable option names, it can check an options object passed by the caller for extraneous fields.

const VALID_OPTS = ['host', 'port', 'path']

// all these are fine:
checkOptions(VALID_OPTS, {})
checkOptions(VALID_OPTS, { host: 'example.com' })
checkOptions(VALID_OPTS, { host: 'example.com', path: '/' })

// this throws an error:
checkOptions(VALID_OPTS, { host: 'example.com', extra: true })

This works reasonably well for my primary use case, which is detecting misspelled fields in user input, alerting them to options that aren’t being used before their program breaks in some other inscrutable way. But there are some subtle problems with it due to how JavaScript’s object system works. A recent bug report concerns the behaviour of this function when properties are added to Object.prototype:

Object.prototype.extra = true

checkOptions(VALID_OPTS, {})
// -> TypeError: Unrecognised option: extra

The caller didn’t pass an extra field, but all object literals implicitly inherit from Object.prototype. The construct for (let key in options) iterates over all the enumerable properties in options, regardless of whether they belong to options itself or are inherited via its prototype. For this reason, it’s common to iterate only an object’s own properties by using a hasOwnProperty() check or using Object.keys():

function checkOptions(validKeys, options) {
  for (let key of Object.keys(options)) {
    if (!validKeys.includes(key))
      throw new TypeError(`Unrecognised option: ${ key }`)
  }
}

Unfortunately this breaks validation when the caller passes an object that’s deliberately constructed using inherited properties:

// this should throw but doesn't
let options = Object.create({ extra: true }))
checkOptions(VALID_OPTS, options)

It’s perfectly valid and common behaviour for callers to construct objects in this way and we want to validate them according to named argument semantics, we just don’t want them getting tripped up things they’re implicitly inheriting from Object.prototype. Alternatively, we could suggest that users get around this by using Object.create(null) to opt out of anything in Object.prototype, but it would be annoying to have to write all our calls like this:

url(Object.create(null, { host: { value: 'example' }, path: { value: '/' } }))

This is much more cumbersome because Object.create() takes property descriptors, not values. It also makes each property non-enumerable by default, making it undetectable by the checkOptions() function. Making that work again would require much more boilerplate:

url(Object.create(null, {
  host: { enumerable: true, value: 'example' },
  path: { enumerable: true, value: '/' }
}))

This would be incredibly annoying to use, much harder to remember and therefore easier to make mistakes with, so we can discard this idea.

There is an argument to say we should only use the options object’s own enumerable properties – this is what Object.assign(), Object.create(), and Object.keys() do so it’s more consistent with various built-in functions, and therefore hopefully less surprising. However we need to bear in mind a key distinction here: iterating an object’s properties, versus accessing them.

All the functions I just mentioned iterate their arguments’ properties. They don’t expect any particular fields to exist, they just iterate whatever is present. When iterating, we have a choice over whether to include inherited properties or not, and these functions choose not to.

A function using named arguments typically doesn’t iterate its input, but accesses specific fields on it – url() accesses options.host, options.port and options.path. The object.field syntax returns the field’s value making no distinction between own and inherited properties. If someone’s added a host property to Object.prototype, then options.host will return its value!

There is a way to access only own properties:

Object.getOwnPropertyDescriptor(options, 'host').value

Even if we invented a shorter function for doing this, say getOwn(options, 'host'), this still wouldn’t be especially pleasant to use and would prevent us using destructuring. The destructuring syntax { host, port, path } = options has the same effect as the options.host syntax and returns the values of inherited properties. We could technically make this work by using the following construct that creates an object with no inherited properties and copies only the own enumerable properties from options:

let { host, port, path } = Object.assign(Object.create(null), options)

But again this is more boilerplate to remember everywhere we perform property access. Unless we want to ban use of destructuring, I don’t see a nice solution to this – property accesses will return data from Object.prototype and there’s not much we can do about it.

All this is pointing at the fact that modifying Object.prototype is inherently unsafe. The usual argument against doing it was always that it added extra keys when you iterated an object, but that’s easy to guard against. The more pressing reason is that it potentially changes the effect of every single property access a program does. Recall how we saw that forbidding callers from passing unknown arguments makes it safer to introduce new options, because we know no existing caller will be using the new name. JavaScript libraries already have to bear in mind that all objects have a default set of properties that we mustn’t use for our own methods and options, because they’re in Object.prototype:

__defineGetter__      __proto__             propertyIsEnumerable
__defineSetter__      constructor           toLocaleString
__lookupGetter__      hasOwnProperty        toString
__lookupSetter__      isPrototypeOf         valueOf

Adding things to this set just creates opportunities for libraries to break because they were assuming that an object won’t have a certain field unless the caller explicitly added it. This even happens within JavaScript’s own APIs – above we saw Object.create(), which uses named arguments in the form of property descriptors. A property descriptor is a named arguments object with possible fields configurable, enumerable, writable, and value. Object.create(), Object.defineProperty() et al don’t check if these are own properties or not, and that means anything added to Object.prototype changes their behaviour.

For example, you could set Object.prototype.enumerable = true and then all properties defined via Object.create() would be enumerable, or set Object.prototype.writable = true to make all new properties mutable. Anyone using these functions would no longer be able to rely on their behaviour, and any function making dynamic use of object properties is affected in this way.

So the solution I’d like to see here is that we refrain from changing Object.prototype entirely. Unfortunately, that’s out of your control if you’re using a package that does this and you can’t change it. Our checkOptions() function that includes inherited properties will throw errors you can’t do anything about, which isn’t useful – we should only throw errors about things the caller is directly responsible for. From that point of view, only checking own enumerable properties makes sense.

That leaves us with a solution like the following for allowing optional arguments and raising errors about unrecognised names:

function url(options) {
  checkOptions(['host', 'port', 'path'], options)
  let { host, port = 80, path } = options

  return `http://${ host }:${ port }${ path }`
}

function checkOptions(validKeys, options) {
  for (let key of Object.keys(options)) {
    if (!validKeys.includes(key))
      throw new TypeError(`Unrecognised option: ${ key }`)
  }
}

With this solution, property access – that is, use of expected arguments like host, port, path – will include inherited properties, so will access anything added to Object.prototype. But checking for unexpected arguments will only include own properties. This works reasonably well for object literals, which inherit from Object.prototype and whose own properties are enumerable. We have considered asking the caller to do tricks with Object.assign() and Object.create(), but they have poor ergonomics and have other failure modes, for example Object.create() makes properties non-enumerable by default so checkOptions() wouldn’t be able to detect them.

An inherent limitation of checkOptions() is it cannot detect non-enumerable properties. It make seem appealing for symmetry reasons to therefore make property access ignore non-enumerable fields, but I think this is likely to be more surprising than not. And, we saw above that attempting to restrict property access beyond what JS does by default introduced too much overhead.

We’ve decided to make checkOptions() stick to own properties and ignore all inherited ones. But there’s a distinction within that category that’s worth investigating: properties inherited from Object.prototype versus any other source. Object literals inherit from Object.prototype by default so it’s unavoidable unless we use Object.create(null). Inheriting from another source can be done on an ad-hoc basis using Object.create() but it’s much more likely to happen by instantiating a class. Even then, any variable fields are more likely to be own properties of the instance – inherited properties are more likely to be the class’s methods, not individual object state.

It would certainly be possible to write a version of checkOptions() that included inherited properties except those originating from Object.prototype, drawing a distinction between properties inherited explicitly using Object.create() or class instantiation, and implicitly from Object.prototype. Would this be valuable? Recall what we’re trying to support here: named arguments, which are typically given with names inline at the call site via object literals. The overriding aim is to prevent typos in those names and make it safe for the library to add new options, and for object literals the Object.keys() version of checkOptions() works fine.

We can take a lesson from TypeScript here and observe that class instances and object literals tend to be used differently, and for different purposes, and it makes sense to treat them differently. Since JavaScript has very few built-in abstractions and Object gets used for many different purposes, we need to make some judgement calls based on how we think a certain object is going to be used in context. When designing APIs we need to think about whether each argument is more likely to be a class instance, an ad-hoc object literal, an opaque reference passed through from somewhere else, and design to make those things as frictionless as possible.