Validating Function Arguments in JavaScript: The Smart Way

Clean Code JavaScript
ui-image Validating Function Arguments in JavaScript: The Smart Way

In software engineering we try to discover and eliminate bugs as soon as possible. One of the most important heuristics here is validation of input/output on functions and methods. If you are going with TypeScript or Flow, you are fine. But if not? Then we have to manually validate at least input (arguments). What would be the best way to do it?

First comes to mind aproba. It’s ridiculously lightweight and quite popular:

import validate from "aproba";
function click( selector, x, y ) {
  validate( "SNN", arguments );
}

Simple enough. For constraints you give a string where each character stands for a corresponding argument. Here we require selector to be a string (“S”) and both coordinates to be numbers (“N”).

But when it comes to optional arguments it gets quite obscure. For example, Node.js function fs.createWriteStream(path[, options]) maintainers of aproba recommend describing as SO|SS|OO|OS|S|O.

First, that is not easy to read. Second, we do not validate the structure of options – any object is considered valid.

Such limitations led me to build an alternative: byContract. It uses JSDoc expressions as constraints instead of single-character codes.

In v3 the cleanest approach is wrapping a function with contract(). Contracts are compiled once at definition time, so repeated calls pay no parsing cost:

import { contract, nonNull, optional, typedef } from "bycontract";

const WriteStreamOptions = typedef({
  flags:     "string=",
  encoding:  "string=",
  fd:        "number=",
  mode:      "number=",
  autoClose: "boolean=",
  start:     "number="
});

const createWriteStream = contract(
  [ "string|Buffer", optional( WriteStreamOptions ) ],
  "WriteStream",
  ( path, options ) => {
    // implementation
  }
);

createWriteStream( "/tmp/out", { flags: "w" } );   // ok
createWriteStream( "/tmp/out", { flags: 42 } );
// ByContractError: createWriteStream: Argument #1: property #flags expected string but got number

Notice typedef() used here without a string name. In v3 it returns the schema object directly – no global registry, no string indirection, no ordering dependency. You just pass the returned value wherever a contract is expected.

Modifier helpers

Instead of memorising JSDoc prefix/suffix characters, v3 ships named helpers:

import { optional, nullable, nonNull, arrayOf, union } from "bycontract";
Helper JSDoc equivalent Meaning
optional("number") "number=" Parameter may be omitted
nullable("number") "?number" Value may be null
nonNull("number") "!number" Rejects null and undefined
arrayOf("string") "string[]" Every element must match the type
union("number","string") "number|string" Accepts any listed type

They return plain strings, so they compose freely:

validate( { name, age, role }, {
  name: nonNull( "string" ),
  age:  nonNull( "number" ),
  role: optional( union( "string", "null" ) )
});

validate( ids,    arrayOf( "number" ) );
validate( scores, arrayOf( nonNull( "number" ) ) );  // rejects [1, null, 3]

Inline validation

For arrow functions or when you want named properties in error messages, the inline style works well:

import { validate, nonNull, optional } from "bycontract";

const pdf = ( path, w, h, options, callback ) => {
  validate( { path, w, h, options, callback }, {
    path:     "string",
    w:        nonNull( "number" ),
    h:        nonNull( "number" ),
    options:  { scale: "?number" },
    callback: optional( "function" )
  });
  // ...
};

Error messages include the property name, which makes debugging faster.

JSDoc decorator

If you already write JSDoc for your classes, validateJsdoc lets you reuse it directly. Requires Babel decorators in legacy mode:

import { validateJsdoc } from "bycontract";

class Fs {
  @validateJsdoc(`
  /**
   * @param {string|Buffer} path
   * @param {string|WriteStreamOptions=} options
   * @returns {WriteStream}
   */
  `)
  createWriteStream( path, options ) {

  }
}

Exit point validation

validate() always returns the incoming value, so it can wrap a return statement directly:

function pdf() {
  // ...
  return validate( returnValue, "Promise" );
}

Polymorphic argument combinations

What if a function accepts different combinations of argument types? Use validateCombo:

import { validateCombo } from "bycontract";

function compare( a, b ) {
  const CASE1 = [ "string",   "string"   ],
        CASE2 = [ "string[]", "string[]" ];
  validateCombo( [ a, b ], [ CASE1, CASE2 ] );
}

Throws when none of the cases match.

Reusing contracts across a codebase

One pattern that works well in larger codebases is defining constraints in a dedicated module and composing them where needed:

/interface/index.js

export const GROUP_REF = {
  id: "string"
};
export const ENTITY = {
  editing:  "boolean=",
  disabled: "boolean="
};
export const GROUP = {
  title: "string=",
  tests: "*[]",
  ...GROUP_REF
};

Then reuse them in action creators:

import { validate } from "bycontract";
import * as I from "interface";

export const addGroup = ( data, ref = null ) => ({
  type: constants.ADD_GROUP,
  payload: {
    data: validate( data, { ...I.ENTITY, ...I.GROUP } ),
    ref:  validate( ref,  I.GROUP_REF )
  }
});

Combining abstract entity and group constraints with { ...I.ENTITY, ...I.GROUP } means adding a new entity subtype is just { ...I.ENTITY, ...I.TEST } or { ...I.ENTITY, ...I.COMMAND }.

Production

Disable validation at runtime:

import { validate, config } from "bycontract";

if ( process.env.NODE_ENV === "production" ) {
  config({ enable: false });
}

Or swap the entire module with Webpack for a zero-byte production build – see the documentation for the NormalModuleReplacementPlugin setup.

Recap

byContract covers all my needs for argument validation. It is more verbose than aproba, but far more flexible. You already know the constraint syntax if you know JSDoc. v3 adds the contract() wrapper and modifier helpers, which make contracts both more ergonomic and faster at runtime (single compiled-once lookup per call). Validation can be stripped entirely from production builds.