Awesome
Banditypes — the mighty 400-byte validator
Check if data conforms to a TS type at runtime — much like zod, yup or superstruct, but in a tiny 400-byte package. Despite the small size, it's not a toy:
- Passes the relevant superstruct test suite.
- Rich built-in types: maps, sets, tuples, literals, and generic union types.
- Mostly API-compatible with the established libraries.
- Supports both deriving TS types from schema and declaring a schema for an existing TS type.
- User-defined types, refinements, and conversions.
- Decent performance — among the top libraries not using code generation.
Banditypes is a 400-byte lib, tradeoffs have been made:
- No detailed errors with messages and paths, just a throw in a predictable location.
- No built-in refinements (empty, integer, etc.).
- Compiled to ES2017: uses ...spreads and arrows. Can be transpiled further down.
- Validation and conversion are mangled, so you have to use the returned object. "Pure validation" is impossible.
- Some syntax might be a bit odd.
Small size is the primary focus of banditypes. It's the smallest validation library, AFAIK, and I'll do my best to keep the core under 400 bytes (unless some critical bugs need fixing, in which case it might go slightly above that).
This is not a library for everybody, but it gets the job done, and it's small. Here's a usage example:
import {
assert,
object,
number,
string,
array,
optional,
fail,
Infer,
} from "banditypes";
const parseGunslinger = object({
name: string(),
kills: number(),
guns: array(string()),
born: object({
state: string().or(optional()),
year: number().map((n) => (Number.isInteger(n) ? n : fail())),
}),
});
// Explicit inference
type Gunslinger = Infer<typeof parseGunslinger>;
const raw = JSON.parse(`{
"name": "Dirty Bobby",
"kills": 17,
"guns": ["Colt 45"],
"born": {
"state": "Idaho",
"year": 1872
}
}`);
try {
const data = parseGunslinger(raw);
// fully type-safe access
console.log(`${data.name} from ${data.born.state} is out to kill ya`);
} catch (err) {
console.log("invalid JSON");
}
400 bytes is an approximate gzip bundle increase from using all built-in validations. It may vary based on the minifier and the amount of validations used. A typical usage (primitives + object + array) is closer to 200 bytes, the core is around 100. Find out more about the measurement technique.
If you like banditypes, check out banditstash — a tiny localStorage wrapper with runtime validation, fully configurable using plugins.
Table of contents
- Install
- Types
- Operators
- Cast functions
- TS-first schemas
- Size measurement
- Acknowledgements
- License (it's MIT)
Install
npm install --save banditypes
Types
banditypes includes all the types you'd expect in a validation library:
// primitives
string();
number();
boolean();
// always fails
never();
// always passes
unknown();
// instanceof check
instance(MyClass);
// checks if value is a function
// static input / output validation is not possible in JS
func();
// { key: string; nullable: string | null; maybe?: string }
object({
key: string(),
// nullable field
nullable: string().or(nullable()),
// optional field
maybe: string().or(optional()),
});
// { key: string }, but don't remove other properties
objectLoose({
key: string(),
});
// number[]
array(number());
// Record<string, boolean>
record(boolean());
// Set<number>
set(number());
// Map<number, boolean>
map(number(), boolean());
// [number, string]
// NOTE: "as const" must be used
tuple([number(), string()] as const);
// value comes from a set
enums([1, 2]); // infers 1 | 2
// mixed-type enums are OK:
enums([true, 0, ""]);
// literal type is a single-value enum:
enums([42]);
Every validator is just a function that returns the argument if it passes validation or throws:
const yes = string()("ok");
const no = string()(0);
- Non-primitive validators always clone the data passed.
object
strips the keys not defined in the schema — to pass-through undeclared keys, useobjectLoose
.tuple
trims the undeclared tail of the array.- Object keys where validation returns
undefined
are stripped. - Strict object and tuple validations (that throw on undeclared keys) are not built-in.
Operators
As a luxury treat, every banditype has two methods: map
for conversion and refinement, and or
for making union types. I could strip around 17 bytes by turning these into functions, but I think it would make the library much less pleasant to use.
or
type1.or(type2)
passes input through type2
if type1
fails. Useful for union types...
const schema = string().or(number());
schema(0); // ok
schema("hello"); // ok
schema(null); // throws
type S = Infer<typeof schema>; // string | number
...nullable or optional types...
// string | undefined
const optionalString = string().or(optional());
// string | null
const optionalString = string().or(nullable());
...and default values — note that it is called on every validation error, not just missing values:
const defaulted = string().or(() => "Manos arriba");
defaulted("hello"); // 'hello'
defaulted(null); // 'Manos arriba'
defaulted({ hello: true }); // 'Manos arriba'
map
banditype.map
can be used for type refinement: run the check and return the value if it passes, or fail()
:
const nonemptyString = string().map((s) => (s.length ? s : fail()));
const date = instance(Date).map((date) =>
Number.isNaN(+date) ? fail() : date
);
Or to convert between types:
const sum = array(number()).map((arr) => arr.reduce((acc, x) => acc + x, 0));
sum([1, 2, 3]); // -> 6
sum(["1", "2", "3"]); // throws
const strFromNum = number().map(String);
strFromNum(9); // -> '9'
strFromNum("9"); // throws
Or maybe as an intersection type, but the inferred type is always the type of the final cast, not the intersection:
const ab = objectLoose({ a: string() }).map(objectLoose({ b: string() }));
type AB = Infer<typeof ab>; // { b: string }
Cast functions
Cast functions are the central concept of banditypes: they accept unknown
argument and return a value of type T
or throw. These all are string-cast functions:
const isString = (raw: unknown) => (typeof raw === "string" ? raw : fail());
const isNonemptyString = (raw: unknown) =>
typeof raw === "string" && raw.length > 0 ? raw : fail();
But so are these, doing type conversion:
const toString = (raw: unknown) => String(raw);
const toJson = (raw: unknown) => JSON.stringify(raw);
Bare cast functions are allowed as arguments in collection types:
const tag = Symbol();
object({
// unique symbol check
tag: (x) => (x === tag ? x : fail()),
});
// array of falsy values
array((raw) => (!raw ? raw : fail()));
Wrapping a cast in banditype()
appends .map
and .or
methods, giving you a custom chainable type (note that the function you pass is mutated):
const mySheriff = banditype<MySheriff>((raw) =>
MySheriff.isSheriff(raw) ? raw : fail()
);
const angrySheriff = mySheriff.map((s) => (s.isAngry ? s : fail()));
TS-first schemas
Unlike some validation libraries, banditypes support pre-defined TS schemas:
interface Bank {
name: string;
money: number;
}
const bankSchema = object<Bank>({
name: string(),
money: number(),
});
Very handy if your types are code-generated from GraphQL.
Size measurement
The 400-byte size reported assumes 5-pass terser and gzip. Brotli is slightly smaller, esbuild
minification is slightly larger, but overall, banditypes is a very very small library. I don't think you can go much smaller. If you have any ideas on how to decrease the size further (without throwing away the chainable API) — let me know!
I use an unconventional (but sensible) approach to size measurement. Instead of measuring the gzip size of the library bundle, I build two versions of a "sample app" — one without validation, one using banditypes. This avoids measuring stuff that won't actually affect the bundle size:
export
keywords and names — lib module is usually inlined, and export names are mangled.- 22-byte gzip End of Central Directory Record that's present in every gzipped file, so your app already has it.
- repetitions of common JS syntax like
=>
orconst
However, it also measures the code for integrating the library into user app — schema definition and actual validation. I can't do party tricks, removing functionality from library core, and making the user implement it manually. Otherwise, you could say "I made a 0-byte library, but you have to check all the types yourself". We optimize the overall bundle size when using the lib, not the lib size itself.
This technique can measure bundle size for different subsets of functionality (all validations; only primitives and objects; only core), and with different minifiers. This makes optimizing for tree-shaking and dead code elimination simple.
This is a great approach, especially for smaller libraries. Check out the samples and code in /bench
Acknowledgements
Superstruct was a major influence on banditypes with its modular design; shout out to Ian Storm Taylor and all the contributors. I also borrowed superstruct's test suite.
Typed by Gabriel Vaquer is another tiny validator that showed me it is possible to deliver the same feature set in a minimal package.