Skip to content

Instantly share code, notes, and snippets.

@a-s-o
Last active May 12, 2016 00:56
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save a-s-o/30c96f704dee46195719ec3013eef7f6 to your computer and use it in GitHub Desktop.
Save a-s-o/30c96f704dee46195719ec3013eef7f6 to your computer and use it in GitHub Desktop.
Testing syntax for a validation/type-system

Create a type

const Num = def('Number', x => typeof x === 'number');

Use a type

Num(1);     //-> 1
Num(NaN);   //-> NaN
Num('abc'); //-> TypeError: Expected "Number", but received ""abc""

Refine types

Use this to create a new type from an existing type with some additional restrictions (called refinements)

const Int = Num.refine('Integer', Number.isInteger);
Int(NaN);   //-> TypeError: [Integer]: Expected "Integer", but received "null"
Int(null);  //-> TypeError: [Integer]: Expected "Integer", but received "null"
Int();      //-> TypeError: [Integer]: Expected "Integer", but received "undefined"

Expand types

Use this to loosen some restrictions on an existing type. For example, below, we still want an integer but it is optional (meaning we are allowing undefined and null to be provided).

const OptionalInt = Int.expand('Integer?', x => x === null || x === void 0);
OptionalInt(NaN);   //-> TypeError: [Integer?]: Expected "Integer?", but received "null"
OptionalInt(null);  //-> null
OptionalInt();      //-> undefined

Notes about predicates

The def function (and refine/expand) accept predicate which either return true/false based on whether a value is valid. Or they can return a string error message. Currently the error messages are just joined up and down the chain along with the type names so the errors can look a bit ugly.

/**
* Internal helpers
*/
// Predicates can return true/false or they can return an error message.
// true, null, undefined and empty string are considered a valid result
function isValid (result) {
return result === true || result === null || result === void 0 || result === '';
}
function validate (name, predicate, x) {
const result = predicate(x);
if (result === false) {
return `Expected "${name}", but received "${JSON.stringify(x)}"`;
}
if (isValid(result)) return void 0;
if (typeof result === 'string') {
return `[${name}]: ${result}`;
}
if (typeof result === 'object' && result.name && result.message) {
return `[${name}/${result.name}]: ${result.message}`;
}
return void 0;
}
// Helper for joining multiple predicates provided as an array
function composer (type, fns) {
return function predicate (x) {
let result;
for (let i = 0, len = fns.length; i < len; i++) {
result = fns[i](x);
const valid = isValid(result);
if (valid && type === 'some') return result;
if (!valid && type === 'every') return result;
}
// For `some`, nothing was valid up to here; so invalid
if (type === 'some') return result || false;
// For `every` everything was valid; so valid
return void 0;
};
}
export const every = composer.bind(null, 'every');
export const some = composer.bind(null, 'some');
/**
* Public api
*/
const Methods = {
is (x) {
return !validate(this.meta.name, this.meta.predicate, x);
},
expand (name, arg) {
// Allow multiple expansions under one name
const predicate = Array.isArray(arg) ? some(arg) : arg;
// eslint-disable-next-line no-use-before-define
return def(name, some([
validate.bind(null, name, predicate),
validate.bind(null, this.meta.name, this.meta.predicate),
]));
},
refine (name, arg) {
// Allow multiple refinements under one name
const predicate = Array.isArray(arg) ? every(arg) : arg;
// eslint-disable-next-line no-use-before-define
return def(name, every([
validate.bind(null, name, predicate),
validate.bind(null, this.meta.name, this.meta.predicate)
]));
}
};
/**
* Constructor
*/
export default function def (name, arg) {
const predicate = Array.isArray(arg) ? every(arg) : arg;
const T = function Type (x) {
const err = validate(Type.meta.name, Type.meta.predicate, x);
if (err && process.env.NODE_ENV !== 'production') throw new TypeError(err);
return x;
};
Object.defineProperties(T, {
is: { value: Methods.is.bind(T) },
expand: { value: Methods.expand.bind(T) },
refine: { value: Methods.refine.bind(T) },
name: { value: name },
meta: { value: { name, predicate } },
});
return T;
}
/**
* Some pre-defined types
*/
export const Nil = def('Nil', x => x === null || x === void 0);
export const Arr = def('Array', x => Array.isArray(x));
export const Obj = def('Object', x => !Nil.is(x) && !Array.isArray(x) && typeof x === 'object');
export const Str = def('String', x => typeof x === 'string');
export const Num = def('Number', x => Number.isFinite(x));
export const Int = Num.refine('Integer', x => Number.isInteger(x));
/**
* Convenience
*/
export function optional (Type) {
return Type.expand(`${Type.meta.name}?`, Nil.is);
}
export function properties (required) {
const keys = Object.keys(required);
return function keysPredicate (x) {
if (!Obj.is(x)) {
return `Expected an object, but received "${typeof x}"`;
}
for (let i = 0, len = keys.length; i < len; i++) {
const k = keys[i];
const Type = required[k];
const err = validate(Type.meta.name, Type.meta.predicate, x[k]);
if (err) return { name: k, message: err };
}
return void 0;
};
}
properties.oneOf = function oneOf (args) {
return function hasAtLeastOne (x) {
for (let i = 0, len = args.length; i < len; i++) {
if (x.hasOwnProperty(args[i])) return true;
}
return `one of ${args.join('/')} is required`;
};
};
import def, { Str, Int, optional, properties } from './def';
import isEmail from 'is-email';
// All types are created using the `def` function which accepts a name as first argument
// and a predicate function (or array of predicate functions) as second argument
// `properties` is a helper for creating a predicate fn that checks for properties
const PersonName = def('PersonName', properties({
first: Str,
last: Str,
initials: Str
}));
// Normal predicates are allowed (isEmail just returns true/false)
const EmailAddress = Str.refine('EmailAddress', isEmail);
const PhoneNumber = def('PhoneNumber', properties({
country: Int,
area: Int,
line: Int,
ext: optional(Str) // `optional` function expands a type to accept nil
// Same as: Str.expand('Str?', x => x === void 0 || x === null)
}));
const Address = def('Address', properties({
careof: Str,
line1: Str,
line2: Str,
city: Str,
province: Str,
country: Str,
code: Str
}));
// A type can be created with multiple predicates to avoid having
// to create a type and then refine it later
const PersonContact = def('PersonContact', [
properties({
name: PersonName,
cell: optional(PhoneNumber),
work: optional(PhoneNumber),
email: optional(EmailAddress),
}),
properties.oneOf([
'cell',
'work',
'email',
])
]);
const CompanyName = def('CompanyName', properties({
legal: Str,
short: Str.refine('String (max 12chars.)', x => x.length < 12) // Here is a refinement
}));
const CompanyContact = PersonContact.refine('CompanyContact', properties({
role: Str,
department: optional(Str)
}));
export {
PersonName,
EmailAddress,
Address,
PersonContact,
CompanyName,
CompanyContact
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment