In my day job, I’m working in a project that consists of a react-native app that is flexible enough to be configured to the needs of many of our clients. A lot of that configuration comes from the server, at runtime. But we also need to configure a lot of things at compile time, mainly assets and variables needed in the native code.
For that end, we have a setup script that receives a configuration file in JSON and makes a bunch of side effects. I realized that if some of these things went wrong, it would silently and fatally affect the JS runtime. So, I decided to implement a validation function of the JSON config file before making any changes, to make sure each config field had the correct type.
I knew this would be complex, because some fields are optional, others are required but only if some other field is set, and so on.
Fortunately, found the perfect abstraction to make this easy and simple. I discovered Spected.
My first rules looked like this:
const isType = R.curry((type, value) => R.type(value) === type);
const rules = {
version: [[isType('Number')], 'Version must be a number'],
build: [[isType('Number')], 'Build must be a number'],
appName: [[isType('String'), 'appName must be a string']]
}
As you can see, we are leveraging the power and Ramda and currying to make amazingly simple validations. But, of course, this is a simplified version. The real-world code had many more rules, and writing the same kind of message over and over again seemed like something I shouldn’t have to do.
If only I could pass Spected a function to make the error message… Such a function would receive the field name and the type it’s supposed to be and return a nice error message, like this:
const typeMessage = (type, field) => `${field} should be a ${type}`;
Looking at Spected source code, I discovered that the error message could already be a function, but it wasn’t passed the field name. So, I submitted a PR to make that happen, and _Voilà _! A whole new world of simple and super-composable validations started to appear!
const typeMessage = (type, field) => `${field} has to be a ${type}`;
const typeRule = type => [
isType(type),
(val, field) => typeMessage(type, field)
];
const isString = typeRule("String");
const isNumber = typeRule("Number")
const rules = {
version: [isNumber],
build: [isNumber],
appName: [isString]
}
Now you should be convinced of the advantages of such an approach, but I’ll make my point stronger by writing about optional fields. If the config file is missing one of these fields, no problem. But If it’s present, we still want to make sure that the type is valid.
Because the validation function is just a function, we can make a higher-order function that takes the validation function and only runs it if the value is not null. Meet unlessNil:
const unlessNil = R.curry((f, val) => R.or(f(val), R.isNil(val)));
Now we can do this:
const optionalTypeRule = type => [
unlessNil(isType(type)),
(val, field) => `if ${field} is set, it must be a ${type}`
];
const validationRules = {
appDescription: [optionalTypeRule('String')]
}
Of course, in order to do this, first the input object must be normalized, so all the missing fields are added with a null value. This can be done with a single function:
const normalize = (spec, input) =>
Object.keys(spec).reduce(
(acc, x) => R.assoc(x, R.propOr(null, x, input), acc),
{}
);
Awesome, right?
Now, I’ll go into more crazy stuff, keep reading if you are interested in making a field depend on another one!
So, let’s say that we have a configuration field notificationsClientId
that is only required if the boolean field getsNotificationsis
true
A curious fact is that the validation functions in Spected, also get the whole input object as the second argument. So I knew that something like this should be possible:
const validationRules = {
notificationsClientId: [
[
dep("getsNotifications", R.equals(true), isType("String")),
"If getsNotifications is true, notificationsClientId is required as a string"
]
]
}
As you can see, the dep function accepts three parameters:
- the field that the current field is dependent on,
- the function to run on said field,
- and the function to run on the current field if the function in the second argument returns
true
dep then returns a validation function ready to be used. It may sound complicated, but look at the implementation. It’s quite simple:
const dep = (field, cond, f) => (val, obj) =>
cond(obj[field]) ? f(val) : true;
That’s it!
So, hopefully this will help you see some of the advantages of composition-friendly APIs and the functional paradigm in general.
Thank you for reading!
Top comments (0)