Giter VIP home page Giter VIP logo

banditypes's Introduction

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:

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

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, use objectLoose.
  • 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 => or const

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.

License

MIT License

banditypes's People

Contributors

jordan-boyer avatar pelikhan avatar thoughtspile avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

banditypes's Issues

[Discussion] Object processing

The current implementation of object validation (object, objectLoose and record) is simple and works reasonably well, but has a few corner cases, so I decided to open a discussion on how to best approach this.

Current implementation. Every cast defined in the schema is always called, whether the key is present in the input object or not. Optional fields are modelled as type().or(optional()), where optional is a value === undefined check under the hood, marking undefined as an allowed value. To avoid adding explicit undefined keys to the output, undefined field-cast outputs are stripped from the final object:

banditypes/src/index.ts

Lines 88 to 95 in 0087d7d

instance(Object).map((raw: any) => {
const res = {} as T;
for (const key in schema) {
const f = schema[key](raw[key]);
f !== undefined && (res[key] = f);
}
return res as WithOptionalProps<T>;
});

Traversing by schema keys checks that all fields declared in the schema are present, and applies defaults as needed, e.g. object({ miss: string().or(() => 'default') })

This works as expected in most cases except for the following two:

  1. never() fields don't "blacklist" a single field, but always fail the entire validation instead. E.g. object({ hack: never() }) always throws, because hack cast is always called. I think this is not important in practice, because object already removes undeclared fields, and the original value was unsafe to access at any rate.
  2. unknown() fields are, technically, always optional in the output, because they return undefined for missing or undefined input, and are then stripped from the output. However, inferring unknown() fields as optional will probably be inconvenient.

record is simpler, because the validation is only called on keys present in the input object. Two (unplanned, but handy) side-effects of the current implementation for records are:

  • record(never()) correctly allows only empty objects.
  • You can filter the entries by value: record(raw => typeof raw === 'string' && raw ? raw : undefined) will silently remove all non-string or empty values.

We should also keep the implementations of the three validations should as similar as possible to get the best compression.

optional handling in object

given the following snippet

const person = object({
    name: string().or(optional()),
    age: number(),
  });

type Person = Infer<typeof person>;

we have the resulting person type

type Person = {
    name: string | undefined;
    age: number;
}

where we could have

type Person = {
    name?: string | undefined;
    age: number;
}

what do you think ?

[Feature] Input & output type infer

Given the following snippet :

const person = object({
    name: string().or(() => 'john'),
    age: number(),
  });

type Person = Infer<typeof person>;

Here Person is :

type Person = {
    name: string;
    age: number;
}

This is a correctly inferred type but it's an output type, because it's the type of the object returned by the person function.

Let's say we have a method that post a user to an API that expect a Person object :

function createPerson(input: unknown) {
    const newPerson = person(input);
    axios.post('/api/person', newPerson); // send a Person object
}

It's weird to see unknown as input in a TypeScript project, here we could write code like createPerson(12) and TS would not complain.

Here we cannot type the input parameter via input: Person because the name property is not optional.

But name should be optional because it has a default value.

Here we see that the same schema has a different input and an output type.

What it can take as input is not the same as what it actually return.

We could imagine a InferInput type that would return the input type of a schema.

type PersonInput = InferInput<typeof person>;

function createPerson(input: PersonInput) {
    const newPerson = person(input);
    axios.post('/api/person', newPerson); // send a Person object
}

Now we have a proper type for the input parameter.

This feature has been implemented in Zod :

const stringToNumber = z.string().transform((val) => val.length);

type input = z.input<typeof stringToNumber>; // string
type output = z.output<typeof stringToNumber>; // number

Breaking bug in 0.2.4 introduce by required in object

It's seem this bug is introduce by the way the d.ts file is generated.

Because when you import the ts file directly typescript does not complain.

here the type produce by 0.2.4

export declare const object: <T = Record<string, never>>(schema: Required<T> extends infer T_1 ? { [K in keyof T_1]: Cast<T[K], unknown>; } : never) => Banditype<Simplify<Partial<T> & Pick<T, { [K_1 in keyof T]: T[K_1] extends Exclude<T[K_1], undefined> ? K_1 : never; }[keyof T]>>>;

and here the type produce by 0.2.3

export declare const object: <T = Record<string, never>>(schema: { [K in keyof T]: Cast<T[K], unknown>; }) => Banditype<Simplify<Partial<T> & Pick<T, { [K_1 in keyof T]: T[K_1] extends Exclude<T[K_1], undefined> ? K_1 : never; }[keyof T]>>>;

I'll try to look into it but I think we should remove this release for now until we can fix it

[Bug] TS-first schemas allow stricter-than-needed checks

When using TS-first collection schemas, the field validations can be stricter than the type passed:

array<string>(enums(['s']))
array<{ key: string }>(object({ key: string(), extra: string() }))
object<{ key: string }>({
  key: enums(['s'])
})

This never results in incorrect runtime values, but can make authoring and maintaining schemas a bit more daunting and cause false negatives. In the case of nested objects, I'm pretty sure there's no way to disallow extra keys with current TS.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.