PropTypes check of object with dynamic keys

React has lots of ways of using PropTypes to check the value of a prop. One that I commonly use is React.PropTypes.shape({...}). However, I recently came across a situation where I have an object that will have dynamic key/values inside. I know that each key should be a string (in a known format), and each value should be an int. Even using a custom prop validation function, it still assumes you know the key of the prop. How do I use PropTypes to check that both the keys and values of an object/shape are correct?

...
someArray: React.PropTypes.arrayOf(React.PropTypes.shape({
  // How to specify a dynamic string key? Keys are a date/datetime string
  <dynamicStringKey>: React.PropTypes.number
}))
...

So again: I want to at the very least check that the value of each key is a number. Ideally, I would also like to be able to check the the key itself is a string in the correct format.


Solution 1:

To validate only the values, you can use React.PropTypes.objectOf.

...
someArray: React.PropTypes.arrayOf(
  React.PropTypes.objectOf(React.PropTypes.number)
)
...

Solution 2:

Note: This answer was written in 2015 when the current version of React was 0.14.3. It may or may not apply to the version of React you're using today.

That's an interesting question. From your question it sounds like you've read about custom type checkers in the docs for Prop Validation. For posterity I'll reproduce it here:

// You can also specify a custom validator. It should return an Error
// object if the validation fails. Don't `console.warn` or throw, as this
// won't work inside `oneOfType`.
customProp: function(props, propName, componentName) {
  if (!/matchme/.test(props[propName])) {
    return new Error('Validation failed!');
  }
}

When implementing type checkers I prefer to use React's built-in type checkers as much as possible. You want to check if the values are numbers, so we should use PropTypes.number for that, right? It would be nice if we could just do PropTypes.number('not a number!') and get the appropriate error, but unfortunately it's a little more involved than that. The first stop is to understand...

How type checkers work

Here's the function signature of a type checker:

function(props, propName, componentName, location, propFullName) => null | Error

As you can see, all of the props are passed as the first argument and the name of the prop being tested is passed as the second. The last three arguments are used for printing out useful error messages and are optional: componentName is self-explanatory. location will be one of 'prop', 'context', or 'childContext' (we're only interested in 'prop'), and propFullName is for when we're dealing with nested props, e.g. someObj.someKey.

Armed with this knowledge, we can now invoke a type checker directly:

PropTypes.number({ myProp: 'bad' }, 'myProp');
// => [Error: Invalid undefined `myProp` of type `string` supplied
//     to `<<anonymous>>`, expected `number`.]

See? Not quite as useful without all of the arguments. This is better:

PropTypes.number({ myProp: 'bad' }, 'myProp', 'MyComponent', 'prop')
// => [Error: Invalid prop `myProp` of type `string` supplied
//     to `MyComponent`, expected `number`.]

An array type checker

One thing the docs don't mention is that when you supply a custom type checker to PropTypes.arrayOf, it will be called for each array element, and the first two arguments will be the array itself and the current element's index, respectively. Now we can start sketching out our type checker:

function validArrayItem(arr, idx, componentName, location, propFullName) {
  var obj = arr[idx];

  console.log(propFullName, obj);

  // 1. Check if `obj` is an Object using `PropTypes.object`
  // 2. Check if all of its keys conform to some specified format
  // 3. Check if all of its values are numbers

  return null;
}

So far it'll always return null (which indicates valid props), but we threw in a console.log to get a peek at what's going on. Now we can test it like this:

var typeChecker = PropTypes.arrayOf(validArrayItem);
var myArray = [ { foo: 1 }, { bar: 'qux' } ];
var props = { myProp: myArray };

typeChecker(props, 'myProp', 'MyComponent', 'prop');
// -> myProp[0] { foo: 1 }
//    myProp[1] { bar: 'qux' }
// => null

As you can see, propFullName is myProp[0] for the first item and myProp[1] for the second.

Now let's flesh out the three parts of the function.

1. Check if obj is an Object using PropTypes.object

This is the easiest part:

function validArrayItem(arr, idx, componentName, location, propFullName) {
  var obj = arr[idx];
  var props = {};
  props[propFullName] = obj;

  // Check if `obj` is an Object using `PropTypes.object`
  var isObjectError = PropTypes.object(props, propFullName, componentName, location);
  if (isObjectError) { return isObjectError; }

  return null;
}

var typeChecker = PropTypes.arrayOf(validArrayItem);
var props = { myProp: [ { foo: 1 }, 'bar' ] };
typeChecker(props, 'myProp', 'MyComponent', 'prop');
// => [Error: Invalid prop `myProp[1]` of type `string` supplied to
//     `MyComponent`, expected `object`.]

Perfect! Next...

2. Check if all of its keys conform to some specified format

In your question you say "each key should be a string," but all object keys in JavaScript are strings, so let's say, arbitrarily, that we want to test if the keys all start with a capital letter. Let's make a custom type checker for that:

var STARTS_WITH_UPPERCASE_LETTER_EXPR = /^[A-Z]/;

function validObjectKeys(props, propName, componentName, location, propFullName) {
  var obj = props[propName];
  var keys = Object.keys(obj);

  // If the object is empty, consider it valid
  if (keys.length === 0) { return null; }

  var key;
  var propFullNameWithKey;

  for (var i = 0; i < keys.length; i++) {
    key = keys[i];
    propFullNameWithKey = (propFullName || propName) + '.' + key;

    if (STARTS_WITH_UPPERCASE_LETTER_EXPR.test(key)) { continue; }

    return new Error(
      'Invalid key `' + propFullNameWithKey + '` supplied to ' +
      '`' + componentName + '`; expected to match ' +
      STARTS_WITH_UPPERCASE_LETTER_EXPR + '.'
    );
  }

  return null;
}

We can test it on its own:

var props = { myProp: { Foo: 1, bar: 2 } };
validObjectKeys(props, 'myProp', 'MyComponent', 'prop');
// -> myProp.Foo Foo
//    myProp.bar bar
// => [Error: Invalid key `myProp.bar` supplied to `MyComponent`;
//     expected to match /^[A-Z]/.]

Great! Let's integrate it into our validArrayItem type checker:

function validArrayItem(arr, idx, componentName, location, propFullName) {
  var obj = arr[idx];
  var props = {};
  props[propFullName] = obj;

  // Check if `obj` is an Object using `PropTypes.object`
  var isObjectError = PropTypes.object(props, propFullName, componentName, location);
  if (isObjectError) { return isObjectError; }

  // Check if all of its keys conform to some specified format
  var validObjectKeysError = validObjectKeys(props, propFullName, componentName);
  if (validObjectKeysError) { return validObjectKeysError; }

  return null;
}

And test it out:

var props = { myProp: [ { Foo: 1 }, { bar: 2 } ] };
var typeChecker = PropTypes.arrayOf(validArrayItem);
typeChecker(props, 'myProp', 'MyComponent', 'prop');
// -> myProp[0].Foo Foo
//    myProp[1].bar bar
// => [Error: Invalid key `myProp[1].bar` supplied to `MyComponent`;
//     expected to match /^[A-Z]/.]

And finally...

3. Check if all of its values are numbers

Happily, we don't need to do much work here, since we can use the built-in PropTypes.objectOf:

// Check if all of its values are numbers
var validObjectValues = PropTypes.objectOf(PropTypes.number);
var validObjectValuesError = validObjectValues(props, propFullName, componentName, location);
if (validObjectValuesError) { return validObjectValuesError; }

We'll test it below.

All together now

Here's our final code:

function validArrayItem(arr, idx, componentName, location, propFullName) {
  var obj = arr[idx];
  var props = {};
  props[propFullName] = obj;

  // Check if `obj` is an Object using `PropTypes.object`
  var isObjectError = PropTypes.object(props, propFullName, componentName, location);
  if (isObjectError) { return isObjectError; }

  // Check if all of its keys conform to some specified format
  var validObjectKeysError = validObjectKeys(props, propFullName, componentName);
  if (validObjectKeysError) { return validObjectKeysError; }

  // Check if all of its values are numbers
  var validObjectValues = PropTypes.objectOf(PropTypes.number);
  var validObjectValuesError = validObjectValues(props, propFullName, componentName, location);
  if (validObjectValuesError) { return validObjectValuesError; }

  return null;
}

We'll write a quick convenience function for testing and throw some data at it:

function test(arrayToTest) {
  var typeChecker = PropTypes.arrayOf(validArrayItem);
  var props = { testProp: arrayToTest };
  return typeChecker(props, 'testProp', 'MyComponent', 'prop');
}

test([ { Foo: 1 }, { Bar: 2 } ]);
// => null

test([ { Foo: 1 }, { bar: 2 } ]);
// => [Error: Invalid key `testProp[1].bar` supplied to `MyComponent`;
//     expected to match /^[A-Z]/.]

test([ { Foo: 1 }, { Bar: false } ]);
// => [Error: Invalid prop `testProp[1].Bar` of type `boolean` supplied to
//     `MyComponent`, expected `number`.]

It works! Now you can use it in your React component just like the built-in type checkers:

MyComponent.propTypes = {
  someArray: PropTypes.arrayOf(validArrayItem);
};

Of course, I would recommend giving it a more meaningful name and moving it into its own module.