a JSON transformer that does type checking too
The JSON data received from an API often differs from the actual data object you'd like to work with in a JavaScript application. To solve this, a client usually has to perform several "cooking steps" to transform the source data into something more digestible. Cuisinier is a JSON transformer that makes it easy to define these cooking steps, such as renaming keys, presence validation, runtime type-checking, converting from a simple type to a complex type, or even merging multiple fields into one.
In JavaScript, data is built up from three separate levels:
- A value.
- A property, which associates a key name (usually a string) with a value.
- An object, which groups zero or more properties together.
Cuisinier similarly defines a transformation using three levels, with a small difference in each level's line of responsability:
- A validator, which is a function that takes any value and either rejects the value by throwing an error, or accepts the value by returning it as-is or converting it into a different type.
- A field, which is an object that wraps around a validator and a
toSourceKey
function, which in turn defines how to convert from the desired property key to the source property key. See the Merging Fields section for a better understanding of how a field works. - A model, which is an object with a model name and a definition that groups multiple fields together, associating each field to a desired property key name. A model itself is also a validator! This mirrors how a JavaScript object can contain another object.
This project is installable using npm:
$ npm install cuisinier
Cuisinier uses a few ES2017 features: Object.setPrototypeOf
,
Object.defineProperties
, and Object.getOwnPropertyDescriptors
. Please make
sure that these are either supported by your runtime, or that they are
polyfilled.
Suppose that we are retrieving a few simple user objects from a server, and that they each fit the following type definition:
interface User {
name: string;
age?: number;
}
Manually writing the validation code for this is extremely tedious, even with just one required and one optional property!
export function User(data) {
const c1 = 'name' in data && typeof data.name === 'string';
const c2 = 'age' in data ? typeof data.age === 'number' : true;
if (c1 && c2) {
return data;
}
throw new TypeError(`${JSON.stringify(data)} is not a User`);
}
With Cuisinier, the code becomes:
import {model, field as f} from 'cuisinier';
import {optional} from 'cuisinier/validator';
import {string, number} from 'cuisinier/validators';
export const User = model('User', {
name: f(string),
age: f(optional(number)),
});
We customarily rename field
to f
in order to shorten the model definition.
Let's say the server responds in the following format:
interface Nomenclature {
identifier: string;
full_name: string;
short_name?: string;
}
But we want it to be:
interface Nomenclature {
username: string;
fullName: string;
shortName?: string;
}
In other words, we want to:
- Rename
identifier
tousername
- Transform
full_name
tofullName
- Transform
short_name
toshortName
We would write this code in Cuisinier:
import {model, fieldFromSnake as f, fieldWithKey} from 'cuisinier';
import {optional} from 'cuisinier/validator';
import {string, number} from 'cuisinier/validators';
export const Nomenclature = model('Nomenclature', {
username: fieldWithKey('identifier', string),
fullName: f(string),
shortName: f(optional(string)),
});
Note that in the imports, we've instead renamed fieldFromSnake
to f
, rather
than a plain field
.
Certain complex data types are not native to JSON. For example, a server may
serialize a Unix timestamp into a number, which would be much more useful as a
Date
object in JavaScript. Suppose we have this data format:
interface ReminderItem {
id: string;
label: string;
created_at: number;
updated_at: number;
}
First, we need to write a custom timestamp validator:
// In `validators.js`:
import {number} from 'cuisinier/validators';
export function timestamp(value) {
// Throw if `value` is not a number.
const epoch = number(value);
return new Date(epoch);
}
Then, we can write our model:
// In some other file:
import {model, fieldFromSnake as f} from 'cuisinier';
import {string} from 'cuisinier/validators';
// The custom validator written earlier:
import {timestamp} from './validators';
export const ReminderItem = model('ReminderItem', {
id: f(string),
label: f(string)
createdAt: f(timestamp),
updatedAt: f(timestamp),
});
Sometimes you want to create a model that has all the fields of another existing model:
interface Acquaintance {
full_name: string;
}
interface Contact extends Acquiaintance {
phone_number: string;
}
The first half is easy in Cuisinier:
import {model, fieldFromSnake as f} from 'cuisinier';
import {string} from 'cuisinier/validators';
export const Acquaintance = model('Acquaintance', {
fullName: f(string),
});
The second half is just as easy:
export const Contact = Acquaintance.extend('Contact', {
phoneNumber: f(string),
});
The extend
method returns a new model based on the existing one. Its second
argument, the extension, could also be simply another full-fledged model,
rather than just a bare-bones definition.
Sometimes a response may contain multiple properties that all logically belong to the same concept:
interface Offer {
name: string;
budget_min: number;
budget_max: number;
}
Ideally we want the outcome to be a nested object structure, like so:
interface Offer {
name: string;
budget: {
min: number;
max: number;
};
}
A multi-field in Cuisinier can achieve this:
import {model, field, fieldWithKey, multiField} from 'cuisinier';
import {string, number} from 'cuisinier/validators';
export const Offer = model('Offer', {
name: field(string),
budget: multiField(
model('Budget', {
min: fieldWithKey('budget_min', number),
max: fieldWithKey('budget_max', number),
}),
),
});
A field usually takes the desired property key name, transforms it into a source
property key name using toSourceKey
, uses that key to get a property from the
source data, and then feeds that value into the wrapped validator. This process
is known as plucking.
A multi-field, on the other hand, completely disregards the desired property key name and feeds the entirety of the source data into the wrapped validator as part of its plucking process.
Cuisinier has first-class TypeScript support, and is itself written in TypeScript. One additional feature in TypeScript is that you can derive a static type from a model:
import {model, field as f} from 'cuisinier';
import {optional} from 'cuisinier/validator';
import {string, number} from 'cuisinier/validators';
export const User = model('User', {
name: f(string),
age: f(optional(number)),
});
export interface User extends model.ResultOf<typeof User> {}
This essentially gives you a type similar to the following, without having to write the entire static type again:
export interface User {
name: string;
age?: number;
}
MIT © UrbanDoor