shapely
shapely copied to clipboard
A simple runtime type checker for javascript supporting records and tagged unions
Shapely
A runtime type checker for javascript with support for records and tagged unions.
Why runtime type checking?
- First, runtime type checking is not an alternative for static type checking. You can use them together as they serve different purposes.
- With technologies like GraphQL, you're already using runtime type checking.
- Custom runtime type checking libraries (like shapely) should be used in places where the shape of data cannot be known in compile time. Client/server communication is one of those places.
Why shapely?
Shapely is unique in that:
- It type-checks regular JS values. It doesn't box values – it doesn't introduce a new data structure. That makes it easy to use alongside state management frameworks like Redux. Read more about why I chose unboxed values.
- It supports tagged unions, similar to that of flow.
- It makes it easy to combine validators to create new, more complex validators.
Usage
With shapely, you first define a validator, and then you verify the shape of some value using that validator.
validate()
Takes a validator and a value:
- If valid, returns the same value.
- If invalid, throws.
Example:
import {validate} from 'shapely';
// Here, String is automatically recognized as a validator
validate(String, 'a'); // returns 'a', since 'a' is a String
validate(String, 10); // throws, since 10 is not a String
isValid()
Takes a validator and a value. Returns true/false.
Example:
import {isValid} from 'shapely';
isValid(String, 'a'); // returns true
isValid(String, 10); // returns false
Strings, Numbers, Booleans
These three are automatically recognized as validators
import {isValid} from 'shapely';
isValid(String, 'a'); // returns true
isValid(Number, 10); // returns true
isValid(Boolean, false) // returns true
Matching any value
import {isValid, any} from 'shapely';
isValid(any, ...); // returns true for any value, including null
Unions
A Union is just a collection of other validators:
import {isValid, union} from 'shapely';
const StringOrNumber = union(String, Number);
isValid(StringOrNumber, 'a'); // returns true
isValid(StringOrNumber, 10); // returns true
isValid(StringOrNumber, {}); // returns false
Records
Records check if an object has certain keys and whether those keys match their respective validators:
import {isValid, record} from 'shapely';
const LaunchVehicle = record({
model: String,
year: Number
});
isValid(LaunchVehicle, {model: 'Falcon 9', year: 2010}); // returns true
isValid(LaunchVehicle, {model: 'Falcon 9', year: 2010, extras: 'whatever'}); // returns true, since only the expected keys are checked
isValid(LaunchVehicle, {model: 'Falcon 9', year: 'some string'}); // returns false
Equality
Strings and numbers can be used as validators to check if a value is strictly equal to them. We'll use this later to construct tagged unions.
import {isValid} from 'shapely';
isValid('a', 'a'); // returns true since they are equal
isValid('a', 'b'); // returns false
isValid(10, 10); // returns true
isValid(10, 11); // returns false
// note that like all other validators, we can use `validate()`
// instead of `isValid()` to get a nice error:
validate('a', 'b'); // throws Error Expected 'b' to equal 'a'
Enums
Not really enums, but you can combine equality validators in a union, to limit a value to a few predefined constants:
import {union, isValid} from 'shapely';
const Color = union('Red', 'Green', 'Blue');
isValid(Color, 'Red'); // returns true
isValid(Color, 'Two'); // returns false
Tagged Unions
Tagged unions are just unions of records:
import {union, record, any} from 'shapely';
const Response = union(
record({
kind: 'Success', // we're using strict equality here
payload: any
}),
record({
kind: 'Failure',
message: String
})
);
isValid(Response, {kind: 'Success', payload: 10}); // returns true
isValid(Response, {kind: 'Failure', message: 'Bad args'}); // returns true
isValid(Response, {kind: 'Failure'}); // returns false
Arrays
import {arrayOf, union, isValid} from 'shapely';
const ArrayOfNumbersOrStrings = arrayOf(union(Number, String));
isValid(ArrayOfNumbersOrStrings, [10, 11, 12]); // returns true
isValid(ArrayOfNumbersOrStrings, ['a', 'b', 'c']); // returns true
isValid(ArrayOfNumbersOrStrings, ['a', 'b', 12]); // returns true
isValid(ArrayOfNumbersOrStrings, [10, 11, {}]); // returns false
Maps
import {mapOf, isValid} from 'shapely';
const MapOfBooleans = mapOf(Boolean);
isValid(MapOfBooleans, {a: true, b: false}); // returns true
isValid(MapOfBooleans, {a: true, b: 'a'}); // returns false
Optional values
import {record, optional} from 'shapely';
const LaunchVehicle = record({
name: String,
year: Number,
firstLaunchYear: optional(Number)
});
isValid(
LaunchVehicle,
{name: 'Falcon 9', year: 2010, firstLaunchYear: 2010}
); // returns true
isValid(
LaunchVehicle,
{name: 'Falcon 5', year: 2002}
); // returns true
Nil
import {isValid, nil} from 'shapely';
isValid(nil, null); // returns true
isValid(nil, undefined); // returns true
isValid(nil, 'a'); // returns false
Deferred validators and Recursive structures
If validator A
requires validator B
, but B
is not yet defined at the time A
is being defined, you can use deferred()
to defer the resolution of B
to a later time:
import {deferred, record} from 'shapely';
const Human = record({
name: deferred(=> HumanName), // HumanName is not defined at the moment
age: Number
});
const HumanName = String;
isValid(Human, {name: 'Anish', age: 50}); // returns true
We can use deferred()
to construct recursive types too:
import {record, union, deferred, nil, any} from 'shapely';
// This is the shape of a BinaryTree. The equivalent flow type would look
// like this:
// type BinaryTree = null | {val: mixed, left: BinaryTree, right: BinaryTree}
const BinaryTree = union(
nil,
record({
val: any,
left: deferred(=> BinaryTree), // we use deferred() because at this line, BinaryTree is not defined yet.
right: deferred(=> BinaryTree),
})
);
isValid(
BinaryTree,
{
val: 10,
left: {
val: 8,
left: {val: 4},
right: {val: 9}
},
right: {
val: 13,
right: {val: 15}
}
}
); // returns true
Custom validators
You can make your own custom validators, provided they implement the Validator
interface:
type Validator = {
isValid (val: mixed): boolean;
getValidationResult (val: mixed): ValidationResult;
}
type ValidationResult =
{isValid: 'true'} |
{isValid: 'false', message: string, score: number}
Example:
import {isValid, validate} from 'shapely';
const Tuple = {
isValid(val) {
return Array.isArray(val) && val.length === 2;
},
getValidationResult(val) {
if (!Array.isArray(val)) {
return {
isValid: 'false',
message: `Array expected, ${typeof val} given.`,
score: 0 // 0 means a complete type mismatch
};
} else if (val.length === 2) {
return {isValid: 'true'};
} else {
return {
isValid: 'false',
message: `A tuple of 2 elements expected. ${val.length} given`,
score: 1 // 1 means the general type matches, but the details don't.
};
}
}
};
isValid(Tuple, [1, 2]); // returns true
isValid(Tuple, [1]); // returns false
validate(Tuple, [1, 2]); // returns [1, 2]
validate(Tuple, [1]); // throws Error: A tuple of 2 elements expected. 1 given
Client/Server usage example
Here we have a simple client/server setup:
- The server implements three functions:
getCartItems
,getItemById
, andaddItemToCart
, and they are written by different team members. - Each of these functions is expected to:
- Return
{kind: 'Success', payload: any}
when invoked successfully. - Return
{kind: 'Failure', message: String}
when invoked unsuccessfully.
- Return
- We need to validate that each function actually follows this protocol every time it's invoked. We'll validate that both on the server and on the client.
// ServerResponse.js
// This file defines the server's resopnse protocol,
// and it'll be used both by the server and the client.
import {record, union, any} from 'shapely';
// This is basically a tagged union that defines the two
// possible shapes of the server's response data. The equivalent
// flow type would look like this:
// type ServerResponse =
// {kind: 'Success', payload: mixed} |
// {kind: 'Failure', message: string}
export const ServerResponse = union(
record({
kind: 'Success',
payload: any
}),
record({
kind: 'Failure',
message: String
})
);
// server.js
// The server imports the three functions, listens for requests from the client,
// routes each request to the correct function, and then validates the returned value
// of that function before sending it to the client.
import {ServerResponse} from './ServerResponse';
import {isValid} from 'shapely';
import {getCartItems} from './fns/getCartItems';
import {getItemById} from './fns/getItemById';
import {addItemToCart} from './fns/addItemToCart';
const fns = {getCartItems, getItemById, addItemToCart};
onRequest((fnName, args, respond) => {
var responsePromise;
if (fns[fnName]) {
responsePromise = fns[fnName](args);
} else {
responsePromise = Promise.reject({
kind: 'Failure',
message: `Unkown function ${fnName}`
});
}
function handleResponse(responseData) {
if (isValid(ServerResponse, responseData)) {
respond(responseData);
} else {
respond({kind: 'Failure', message: 'Internal server error'});
}
};
return responsePromise.then(handleResponse, handleResponse);
});
// client.js
// The client simply sends requests to the server,
// and returns a promise that either fulfills or rejects,
// based on the server's response.
import {isValid} from 'shapely';
import {ServerResponse} from './ServerResponse';
export function request(fnName, args) {
return callServer(fnName, args)
.then((response) => {
if (!isValid(ServerResponse, response)) {
return Promise.reject('Invalid server response');
}
if (response.kind === 'Success') {
return response.payload;
} else {
return Promise.reject(response);
}
});
};
Why unboxed values?
The main difference between shapely and other projects is that it uses normal, unboxed JS values. [email protected], used to box values inside containers (e.g joe = new Person({age: 10})
instead of just joe = {age: 10}
), but in 0.2, I decided to use regular JS values. My reasons for that change were:
- I use Redux for state management, and my app's store is an immutable.js map. Since immutable.js doesn't understand boxed values, it just stores them as is. That would make part of the app's state, immutable.js-style values, and other parts, shapely values. As the app grew, that just became confusing.
- Validating an unboxed value is an O(N) operation, while validating a boxed value is an O(1) operation. That's good, but after using the boxed design for two months, I realized that I never needed that O(1) operation in any part of my code. I found that once a value get validated and put inside the store, it never needs to be validated again. Or, if a value is already in the store, you never have to validate it. So, I never used the performance improvement that came with boxed values.
- There was one place where I did use the performance improvement of boxed values, and that was in pattern matching (though pattern matching is a big word for that simple feature I implemented). But I then realized that simple flow-style type refinements can do the job too. I mainly used pattern matching for detecting different variants of a union, but now that shapely has tagged unions, I just check for the tag and determine the right variant. That's fast too.
- With the unboxed design, shapely can work with seamless-immutable values. You can also easily retrofit it to work with immutable.js or mori values.
- Unboxed values can be used as react props. So too can boxed values, but then you'll have two sets of react components that either support boxed or unboxed values. I realized that most of the components I made needed to support both. That led to an awkward codebase.
Development
Shapely is itself written in Flow, but like all flow code, it still compiles if you don't have flow installed on your system. We use Babel for compilation, Webpack for bundling, and LiveScript for the tests.
To watch the files and recompile:
$ cd path/to/shapely
$ npm run dev
To run the tests:
$ npm test
or:
$ npm run test:watch
License
MIT