Getting the number of parameters a function expects

Just a little side project to get back into a bit of JS.

Looking at partial application, but one of the issues in JS is being able to reliably get the number of parameters a function expects.

The length property of a function ignores default arguments and spread parameters.

I’m sure there is a better way of doing this, but have opted to call the toString method and use some regexery to try and solve this.

// inspect:
// A function to inspect a function's parameters
// Returns the name, parameters and length
// The length includes default arguments aswell as spread arguments

const groupsRx = {
  // A collection of regexes to match balanced groups to a depth of 3
  // e.g. match ... [1, 2, [3]] ... or ... { x: 2, y : { z: 3 }} ...
  // Used to match default parameter groups e.g. function (x, y = →[1, 2]←))
  groups: {
    parens: /\([^)(]*(?:\([^)(]*(?:\([^)(]*(?:[^)(]*)*\)[^)(]*)*\)[^)(]*)*\)/,
    braces: /\{[^}{]*(?:\{[^}{]*(?:\{[^}{]*(?:[^}{]*)*\}[^}{]*)*\}[^}{]*)*\}/,
    brackets: /\[[^\]\[]*(?:\[[^\]\[]*(?:\[[^\]\[]*(?:[^\]\[]*)*\][^\]\[]*)*\][^\]\[]*)*\]/
  },
  
  join() {
    // concatenates the regex groups with pipe returning one regex
    const groups = Object.values(this.groups)
    return new RegExp(groups.map((rx) => rx.source).join('|'), 'g')
  }
}

function inspect(fn) {
  // matches all the parameters within parentheses
  const params = fn.toString().match(groupsRx.groups.parens)[0].slice(1, -1)
  const balancedGroups = groupsRx.join()
  // matches groups e.g. (x, y = foo→('bar')←, z = →[1,2,3]←)
  const groups = params.matchAll(balancedGroups)

  // replace the groups with a % placeholder so that
  // params can be split on commas into an array, and
  // then replace placeholders with the groups
  const parameters = params
    .replace(balancedGroups, '%')
    .split(/\s*,\s*/)
    .map((param) => param.replaceAll('%', () => groups.next().value))

  return { name: fn.name, parameters, length: parameters.length }
}

Examples

const foo = (x, y, z = [1,2,3], ...args) => {}
const bar = (x, y = foo(1, 2), { property: prop }) => {}

console.log(foo.length) // 2 
console.log(inspect(foo))
/* 
{
  'name': 'foo',
  'parameters': ['x', 'y', 'z = [1, 2, 3]', '...args'],
  'length': 4
} 
*/

console.log(bar.length) // 1
console.log(inspect(bar))
/* 
{
  'name': 'bar',
  'parameters': ['x', 'y = foo(1, 2)', '{ property: prop }'],
  'length': 3
} 
*/

I may well be barking up the wrong tree here. I know it’s a tad flakey, so any input is welcome.

1 Like

Hey Russ, a couple of thoughts:

  • Your regexes might struggle with deeply nested structures or more complex default values (e.g., functions as default parameters, or destructured objects).
  • The current solution may not handle certain edge cases, such as arrow functions without parentheses, multiline function signatures, or functions with comments inside the parameter list.
  • Woah!! That regex… I would hazard a guess that when you revisit this code in 3 months you will not have a clue how to modify or adapt it.

One option to consider for a more robust solution, is to use an Abstract Syntax Tree parsing library, such as @babel/parser. This can parse a function’s code and give you a structured representation of its parameters without needing to resort to regex.

Here’s a quick example of how you might go about things.

mkdir params
cd params
npm init -y
npm i '@babel/parser'

Then the code:

const babel = require('@babel/parser');

function inspect(fn) {
  const fnString = fn.toString();

  // Parse the function string into an AST
  const ast = babel.parse(fnString, { sourceType: 'script' });

  // Retrieve the function node from the AST (assuming it's the first statement in the body)
  const functionNode = ast.program.body[0].type === 'ExpressionStatement'
    ? ast.program.body[0].expression
    : ast.program.body[0];

  // Extract parameters from the function node
  const params = functionNode.params.map((param) => {
    // Handle default parameter case
    if (param.type === 'AssignmentPattern') {
      return `${param.left.name} = ${fnString.slice(param.right.start, param.right.end)}`;
    }

    // Handle rest parameter case
    if (param.type === 'RestElement') {
      return `...${param.argument.name}`;
    }

    // Handle object destructuring
    if (param.type === 'ObjectPattern') {
      return `{${param.properties.map((prop) => prop.key.name).join(', ')}}`;
    }

    // Handle array destructuring
    if (param.type === 'ArrayPattern') {
      return `[${param.elements.map((el) => el.name).join(', ')}]`;
    }

    // Simple parameter name
    return param.name;
  });

  return { name: fn.name || '(anonymous)', parameters: params, length: params.length };
}

const foo = (x, y, z = [1, 2, 3], ...args) => {};
const bar = (x, y = foo(1, 2), { property: prop }) => {};

console.log(inspect(foo));
console.log(inspect(bar));

Outputs:

{
  name: 'foo',
  parameters: [ 'x', 'y', 'z = [1, 2, 3]', '...args' ],
  length: 4
}
{
  name: 'bar',
  parameters: [ 'x', 'y = foo(1, 2)', '{ property }' ],
  length: 3
}

HTH

1 Like

That’s brilliant Jim, a much more sensible solution.

It was in the back of my mind, I remember m_hutley was trying to point me in this direction on one of my other heavy regexed projects.

I now want to know how difficult it would be to implement an Abstract Syntax Tree parsing library of my own, but that might be for another day :biggrin:

1 Like

With regards regexes, just to share, I did come across this interesting page on balancing parentheses with regexes

Regular expression to match balanced parentheses

In particular two libraries that handle the missing recursion from JS’ regex toolset. (unless that has now been implemented, and I missed it :slight_smile: )

XRegExp

As an example XRegExp can be used like this

// Basic usage
const str1 = '(t((e))s)t()(ing)';
XRegExp.matchRecursive(str1, '\\(', '\\)', 'g');
// -> ['t((e))s', '', 'ing']
1 Like

Oh interesting. I didn’t know you could do that.

1 Like

If you care about your sanity at all I wouldn’t go there…

1 Like

Sanity is boring and overrated. :crazy_face::crazy_face::crazy_face:

So first count the number of commas; if that = length, you have your answer.
If it doesnt, find the number of ='s, and see if the last , is followed by a ...; add length + ='s + spreadyesorno, and get your answer?
(I feel like this got infinitely more complex than it needed to be?)

You could quite easily have a default argument of x = [1,2,3,4,5,6], in which case that one ='s is actually 5 commas.

As for if that = length, we don’t have the length, that is the issue.

At first I did think you had sussed it though, and I had missed the obvious :smiley:

Just to give this some context. Say for instance I write a function for currying other functions. The typical way is to take the length of the given function and subtract the length of subsequent passed arguments.

Something like this

const curry = (fn) => {
  let paramCount = fn.length
  
  const wrapper = (...args) => {
    // if we have the correct number of arguments
    // call the function otherwise return another wrapper
    return paramCount - args.length > 0
      ? (...nextArgs) => wrapper(...args, ...nextArgs)
      : fn(...args)
  }
  
  return wrapper
}

The issue

I write a function to sum products like this

const sumProduct = curry((n, ...args) => args.reduce((x, y) => x + y * n, 0))

I then try this

const sumByFive = sumProduct(5) // should return a function, but returns 0
sumByFive(1, 2, 3) // Uncaught TypeError: sumByFive is not a function

Due to the length of the arrow function being 1 instead of 2 it fails

One way around this is to add an optional argument where you can specify the length

// In this version you have the option to specifiy the number of parameters
const curry = (fn, paramCount = fn.length) => {

  const wrapper = (...args) => {
    return paramCount - args.length > 0
      ? (...nextArgs) => wrapper(...args, ...nextArgs)
      : fn(...args)
  }
  
  return wrapper
}

This will now work

// we can specify the number of arguments to expect to 2
const sumProduct = curry(
  (n, ...args) => args.reduce((x, y) => x + y * n, 0), 2
)

const sumByFive = sumProduct(5) // returns a function
sumByFive(1, 2, 3) // 30

It would be nice to have an inspect module to help with this, although I feel pulling in an entire library like babel to do that is somewhat excessive. I am interested in the module used by babel, ‘acorn’ though

Edit: fixed the above flawed impementations of curry. Why testing is important :slight_smile:

A more succinct version without the wrapper and using bind

const curry = (fn, paramCount = fn.length) => {
    // returns a new function with fixed arguments
    return (paramCount > 0)
        ? (...args) => curry(fn.bind(null, ...args), paramCount - args.length)
        : fn();
};
  • So first count the number of commas; if that = length, you have your answer.
    • 5 != 0, so no.
  • If it doesnt, find the number of ='s, and see if the last , is followed by a ... ; add length + ='s + spreadyesorno, and get your answer?
    Length = 0, + 1 Equals, +0 spread = 1. Correct answer.

I didnt say add it to the number of commas for a very specific reason… I said add it to Length, which is the number of required parameters without a default value.

I guess you could do it without the commas calculation at all, but it involves searching the string more for simple functions. function.length + count(=) + (int) IsLastParameterASpread

1 Like

I’m being slow, yes that makes sense.

Edit: Just to throw a spanner in the works

This is an example from MDN’s destructuring, and was one of the samples I was using last night in my tests

function drawChart({
  size = "big",
  coords = { x: 0, y: 0 },
  radius = 25,
} = {}) {
  console.log(size, coords, radius);
  // do some chart drawing
}

It is looking more and more, like giving an option to specify the number of params is a sensible route.

That doesn’t mean I am not interested in pursuing other options though.

It does seem to me that this is a bit of a draw back with JS not providing the in-built properties to access these details, default count, …args etc.

my immediate thought at seeing that is “get a better function definition”.
But fair enough. I suppose I am limiting the possibilities of the parameters.

I can’t say it’s a format I have used, but you never know.

I mean, at some point it just becomes “Write a Javascript Compiler”, because you’d have to tokenize the function parameters to handle all the possibilities.

2 Likes

Just to add, following Jim’s advise on AST’s I looked into Acorn parser. This is the parser used by babel and describes itself as a lightweight JS parser.

Inspect function

// import Acorn AST parser
// https://github.com/acornjs/acorn
// https://cdnjs.cloudflare.com/ajax/libs/acorn/8.12.1/acorn.min.js

const inspectParams = (fn) => {
    /**
     * Inspects a function's parameters including default assignments and rest parameters.
     * Arguments:
     * @param {Function} fn: The function to inspect.
     * @returns {Object}: An object containing the function's name, parameters, and length.
     */
    const fnString = fn.toString();
    const script = acorn.parse(fnString, {ecmaVersion: 2020}).body[0];
    const params = script.expression?.params || script.params;
  
    return { 
        name: fn.name || '(anonymous)', 
        parameters: params.map(({start, end}) => fnString.slice(start, end)),
        length: params.length 
    };
};

Examples

const add1 = (x, y=[1,2,3], ...args) => console.log(x, y, ...args);
  
function add2(x=null, y={x:1,y:2}, ...args) {
    console.log(x, y, ...args);
}

console.log(inspectParams(add1));
/*
  {
    "name": "add1",
    "parameters": [
      "x", "y = [1, 2, 3]", "...args"
    ],
    "length": 3
  }
*/
console.log(inspectParams(add2));
/*
  {
    "name": "add2",
    "parameters": [
      "x = null", "y = { x: 1, y: 2 }", "...args"
    ],
    "length": 3
  }
*/

I appreciate this is a sledge hammer to crack a nut, but I can’t figure a better alternative.

1 Like

Very much procrastinating, I need to stop writing these functions. I did get to work with jest and jasmine for testing though, which wasn’t bad.

Partial script

import { inspectParams } from './inspect-params.js';

function partial(fn, ...args) {
    /**
     * Partially applies arguments to a function.
     *
     * @param {Function} fn: The function to be partially applied.
     * @param {Any} ...args: The arguments to be partially applied.
     * @returns {Function | Any} Returns a binding function or a result.
     * @throws {TypeError} If the first argument is not a function.
    */
    if (typeof fn !== 'function') {
        throw new TypeError(`${typeof fn} ${fn} is not a function`);
    }

    const arity = inspectParams(fn).length; // <-- here :)
    let argCount = args.filter(x => x !== undefined).length;

    if (arity <= argCount) {
        return fn(...args);
    }

    return function wrapper (...rest) {
        let i = 0;
        let j = 0;

        for (; i < args.length || j < rest.length; i++) {
            if (args[i] === undefined) {
                if (rest[j] !== undefined) {
                    args[i] = rest[j];
                    argCount++;
                }
                j++;
            }
        }

        return argCount < arity ? wrapper : fn(...args);
    };
}

Example Usage

// Helper function for demethodizing built-in methods
const demethodize = fn => (_this = null, ...args) => fn.apply(_this, args);
// Note: if partial used fn.length it would invoke the function immediately believing it has no arguments
const filter = partial(demethodize([].filter));

// using 'undefined' as a placeholder for partial application
const filterEvens = filter(undefined, x => x % 2 === 0);
console.log(filterEvens([1, 2, 3, 4, 5, 6])); // [2, 4, 6]

The flip side of that is that it is thoroughly battle tested and that the code you posted in #16 is way more readable than the code from #1.

Are you concerned about the size of the library or possible performance impact?

2 Likes

The flip side of that is that it is thoroughly battle tested and that the code you posted in #16 is way more readable than the code from #1.

Absolutely, I agree.

That said have a peek at Acorn’s code
https://cdnjs.cloudflare.com/ajax/libs/acorn/8.12.1/acorn.js

Also note the light-weight Acorn is still 6000+ lines of code.

I had a similar dilema with Python and using the inspect module. The inspect module is also dependent on a whole list of other imports as well.

A bit of a difference is Python does provide attributes e.g.

def foo(x, y:int = 5, z:str = '', *args, **kwargs):
    pass

# positionals e.g. x, y and z
print(foo.__code__.co_argcount) # 3
print(foo.__defaults__) # (5, '')

# *args
if foo.__code__.co_flags & 0x4:
    print('has *args') # has *args

# **args
if foo.__code__.co_flags & 0x8:
    print('has **kwargs') # has **kwargs

# annotations
if hasattr(foo, '__annotations__'):
    print(foo.__annotations__) # {'y': <class 'int'>, 'z': <class 'str'>}

bar = str.split

# If has a text signature e.g. built-ins like string's split
if hasattr(bar, '__text_signature__') and bar.__text_signature__:
    print(bar.__text_signature__) # ($self, /, sep=None, maxsplit=-1)

We only have a functions length in JS, which is a bit limiting.

The argument for using imports is of course they are battle tested. My code from #1 is flakey and likely to fail at the first hurdle.

One other reason I could justify using imports in Python though is that I knew that they were often compiled in C, which meant they were also much more performant.

It just seems a bit crazy that for say two helper functions, like partial and curry, it requires what is a relatively large module.

Conclusion, I am not sure about this :slight_smile:

Code complexity will be proportional to the language’s complexity. shrug