Skip to content

kutruff/nimbit

Repository files navigation

Nimbit

Ultra-tiny TypeScript schema validation with static type inference and guaranteed reflection.

Nimbit Size Nimbit CI status

npm install nimbit

Nimbit is an evolution of Zod's excellent design, and has all the best parts of Zod with a bunch of improvements.

Nimbit Zod
Build Size Build Size
âś… Super tiny footprint.
âś… Less noise in your code - no longer need parenthesis everywhere.
âś… Reflection/introspection always guaranteed.
âś… Object recursion and mutual recursion is more natural and easy.
âś… Type to type property mapping.
âś… Coercion and pipelining are streamlined.
âś… Everything is pushed to userland as much as possible.

TLDR; Zod differentiators: Recursive Objects, Objects, to(), mapProps(), mapPropsPicked()

Nimbit is open for feedback and is approaching 1.0.

Docs

The documentation here is a modified snap of Zod's documentation to help compare Nimbit to Zod.

Introduction

Nimbit is a TypeScript-first schema declaration and validation library just like Zod. However, it is extremely small and strives to keep as much functionality and typing in userland as possible. Furthermore, Nimbit ensures all types are always guaranteed to be reflected upon no matter what.

Requirements

  • TypeScript 5.1+!

  • You must enable strict mode in your tsconfig.json. This is a best practice for all TypeScript projects.

    // tsconfig.json
    {
      // ...
      "compilerOptions": {
        // ...
        "strict": true
      }
    }

From npm

npm install nimbit      # npm
yarn add nimbit          # yarn
pnpm add nimbit          # pnpm

Basic usage

Creating a simple string schema

import { string } from 'nimbit';

// creating a schema for strings
const mySchema = string;

// parsing
mySchema.parse('tuna'); // => "tuna"
mySchema.parse(12); // => throws error

Creating an object schema

import { obj, string, type Infer } from 'nimbit';

const User = obj({
  username: string
});

User.parse({ username: 'Ludwig' }); // {username: "Ludwig"}}

// Define the inferred TypeScript type
type User = Infer<typeof User>;
// { username: string }

Primitives

import { any, bigint, boolean, date, never, nul, number, string, symbol, undef, unknown } from 'nimbit';

// primitive values
string;
number;
bigint;
boolean;
date;
symbol;

// empty types
undef;
nul;

// catch-all types
// allows any value
any;
unknown;

// never type
// allows no values
never;

Basic Coercion

First, there are built in helpers for coercing primitive types.

asString.parse('tuna'); // => "tuna"
asString.parse(12); // => "12"
asString.parse(true); // => "true"
asString; // String(input)
asNumber; // Number(input)
asBoolean; // Boolean(input)
asBigint; // BigInt(input)
asDate; // new Date(input)

These coercion data types are full fledged Types equivalent to their output type. In other words, expect(asString).toEqual(string) is true. The only difference is the behavior of parse().

Boolean coercion

Boolean coercion is very simple! It passes the value into the Boolean(value) function, that's it. Any truthy value will resolve to true, any falsy value will resolve to false.

asBoolean.parse('tuna'); // => true
asBoolean.parse('true'); // => true
asBoolean.parse('false'); // => true
asBoolean.parse(1); // => true
asBoolean.parse(0); // => false
asBoolean.parse([]); // => false
asBoolean.parse(undefined); // => false
asBoolean.parse(null); // => false

where() for basic validation

Use the where() method and returning true if the data is valid.

const myNumber = number.where(x => x > 100);
myNumber.parse(150); // => 150
myNumber.parse(80); // => throws error
const nonEmptyString = string.where(x => x !== '');
nonEmptyString.parse('nice'); // => nice
nonEmptyString.parse(''); // => throws error

You can create reusable validators as functions as well:

const nonEmpty = (x: string) => x !== '' && string != null;

// Just wrap validators with parameters in another function.
const min = (min: number) => (x: number) => x >= min;
const range = (min: number, max: number) => (x: number) => x >= min && x <= max;

const matches = (regex: RegExp) => (x: string) => regex.test(x);
const email = string.where(matches(/^([A-Z0-9_+-]+\.?)*[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i));

const formData = obj({
  name: string.where(nonEmpty),
  age: number.where(min(0)),
  quantity: number.where(range(1, 100)),
  mainEmail: email
});

//all of these would fail
formData.parse({
  name: '',
  age: -1,
  quantity: 0,
  mainEmail: 'bob@fcom'
});

There are a few overloads to where() that let you customize the error message.

to() for user defined coercion and parsing

Nimbit has the versatile to() function for manipulating and transforming data between types in parsing pipelines.

For example, here's how to take a Date object and turn it into a string:

const asIsoString = date.to(string, x => x.toISOString());

asIsoString.parse(new Date('2035-02-25')); // => "2035-02-25T00:00:00.000Z"
asIsoString.parse('2035-02-25'); // => throws

Writing your own fully validated coercion is extremely easy. For example, here is a way to implement the asNumber coercion:

const target = unknown.to(number, x => Number(x)).where(x => !isNaN(x));

asNumber.safeParse('12'); // => {success: true, data: 12}
asNumber.safeParse('hi'); // => {success: false, error: {kind: 'condition', actual: 'hi'}}

After that you can use asNumber any place you would use a string and when you reflect on asNumber it will appear identical to string.

The above says: "Allow any unknown value, try to convert it to a number, and if it is not `NaN``, parsing is successful. Otherwise, fail"

The first argument is the output type of your coercion. This paradigm is important to keep all types defined by Nimbit to be reflective and maintain strong typing throughout your code.

Also we are calling safeParse() in this example. This method is just like parse, but doesn't throw. It returns an object describing the result of the parse.

Moving on, that error message isn't so great though. Let's improve it:

const target = unknown.to(number, x => Number(x)).where(x => !isNaN(x), 'failed to convert to number');
asNumber.safeParse('hi');
// => {success: false, error: {kind: 'condition', message: 'failed to convert to number', actual: 'hi'}}

Let's get crazier and return a full error type::

const asNumber = unknown
  .to(number, x => Number(x))
  .where(
    x => !isNaN(x),
    x => invalidTypeError('asNumber', x, `failed to convert ${x} to a number.`)
  );
asNumber.safeParse('Hello');
// => {success: false, error: {kind: 'invalid_type', expected: 'asType', actual: 'Hello', message: 'failed to convert Hello to a number', }}

Now, since this is going to be used a lot let's just skip the pipelining of to() and do it in a single step:

const asNumber = to(number, (value: unknown) => {
  const result = Number(value);
  return !isNaN(result) ? pass(result) : failInvalidType('asNumber', value);
});

You have complete control of what happens when you try to coerce a value and how the error handling will happen. The above also lets you omit unknown.to() if you wish. unknown.to() as it is implied.

If the coercion succeeds you just need to return pass(result). If the coercion fails, return fail(). You can also pass a rich error object to fail() to provide a custom error message. There's also a convenience method for describing type conversion failures that show the expected result vs supplied value failInvaldType('asNumber', value) See custom error types for fully being able to customize your error messages.

If your coercion throws an error, there is also a convenience overload that lets you return whatever error you wish.

export const asBigint = to(
  bigint,
  (value: string | number | bigint | boolean) => BigInt(value),
  value => invalidTypeError('asBigint', value)
);

You've already seen the chaining above, but you can use .to() to do things like restricting input types before passing it to a conversion.

const stringsThatAreNumbers = string.to(asNumber);

stringsThatAreNumbers.parse('12'); // => 12
stringsThatAreNumbers.parse('hello'); // => throws
stringsThatAreNumbers.parse(150); // => throws since only strings are allowed first

As a convenience, you can use tweak() which is a shorthand for to() when you are not altering the type of the data. It will always pass your result to the pass function.

const prefixedString = string.tweak(x => `_${x}`);

prefixedString.parse('hello'); // => "_hello"
prefixedString.parse(1); // => throws

Objects

// all properties are required by default
const Dog = obj({
  name: string,
  age: number,
});

// extract the inferred type like this
type Dog = Infer<typeof Dog>;

// equivalent to:
type Dog = {
  name: string;
  age: number;
};

.shape

Use .shape to access the schemas for a particular key. Allows you to reflect against the type.

Dog.shape.name; // => string schema
Dog.shape.age; // => number schema

.k

Use .k to get an autocompletable set of the shape's keys.

Dog.k; // => {name: "name", age: "age"]}

.mapProps()

You can use mapProps() to add validations and coercions to existing types. This makes it natural to validate inputs with the goal of reaching an underlying model type.

Given a source type, pass an object with properties mapping function for the properties on the source type. Each property is a function that is passed the existing property from the source type. You can then add whatever validations you wish to the property or you may even override it completely.

Note that any properties not specified in the object will be copied over as is. If you wish to remove a property, you can use mapPropsPicked().

const Person = obj({
  name: string,
  age: number
});

//Lets add some validations to Person
const PersonInput = Person.mapProps({
  name: p => p.opt(), //name is now optional
  age: p => p.where(x => x > 10) // age now must be greater than 10
});

PersonInput.parse({ age: 42 }); // { age: 42 }
PersonInput.parse({ name: 'Bob', age: 42 }); // { name: 'Bob', age: 42 }
PersonInput.parse({ name: 'Bob', age: 9 }); // fail: age must be greater than 10

type PersonInput = Infer<typeof PersonInput>;
// {
//     name?: string | undefined;
//     age: number;
// }

Here's a more complex example. Notice that we put an validator on age and we still want that validation to execute.

const Address = obj({ street: string, zipcode: string }); //.where(x => x.length === 5) });

const Person = obj({
  name: string,
  age: number.where(x => x > 0), //An existing validation
  address: Address,
  isActive: boolean,
  title: string
});

const PersonInput = Person.mapProps({
  name: p => p.default(''), //name will default to empty string
  age: p => asNumber.to(p), //age is coerced to a number and then passed to original age and verified > 0
  address: p =>
    p.mapProps({
      street: p => p.where(x => x.includes('St.')), //validate nested property
      zipcode: p => number.to(asString).to(p) //require number but coerce to string and Address will verify length.
    }),
  isActive: p => enumm('state', ['active', 'inactive']).to(p, x => x === 'active') //enum coerced to bool
});
type PersonInput = Infer<typeof PersonInput>;
// type PersonInput = {
//   name: string;
//   age: number;
//   title: string;
//   address: {
//       street: string;
//       zipcode: number;
//   };
//   isActive: boolean;
// }
// try {

const result = PersonInput.parse({
  age: '42',
  isActive: 'active',
  title: 'Mr.',
  address: { street: '123 Main St.', zipcode: 10101 }
});
// success: true
// {
//   name: '',                   // string;
//   age: 42,                    // number;
//   title: 'Mr.',               // string;
//   address: {
//       street: '123 Main St.', // string;
//       zipcode: '10101',       // string;
//   },
//   isActive: true              // boolean;
// }

Notice that the title property is not mentioned in the arguments. It will be copied over from Person as is.

The example above is equivalent to:

const person = Person.shape;
const address = Person.shape.address.shape;

const personVerifierWithShape = obj({
  ...person,
  name: person.name.default(''),
  age: asNumber.to(person.age),
  isActive: enumm('state', ['active', 'inactive']).to(person.isActive, x => x === 'active'),
  address: obj({ ...address, street: address.street.where(x => x === '123 Main St.') })
});

.mapPropsPicked()

mapPropsPicked() behaves exactly like mapProps() except the properties that you don't mention will be omitted from the output type

This is a great and safe way to validate API requests without having to repeat property names and keeping types in sync with your underlying data model.

const Person = obj({
  name: string,
  age: number
});

const ChangeNameRequest = Person.mapPropsPicked( {
  name: p => p.where(x => x !== '')
});
ChangeNameRequest.parse({ name: 'Bob', age: 42 }); // { name: 'Bob' }

const ChangeAgeRequest = Person.mapPropsPicked({
  name: p => asNumber.to(p);
});
ChangeAgeRequest.parse({ '42' }); // { age: 42 }

.extend()

You can add additional fields to an object schema with the extend function.

const DogWithBreed = Dog.extend({
  breed: string
});

You can use extend to overwrite fields! Be careful with this power!

.mapShape()/.mapShapePicked()

These are identical to mapProps() and mapPropsPicked() except they return only the shape of the mapped object. This is useful for providing ultimate flexibility.

// The shape is mapped
const PersonInputShape = Person.mapShape({
  name: p => p.where(x => x !== '')
});

//Now to get an object
const PersonInput = obj(PersonInputShape);

.merge()

Equivalent to A.extend(B.shape).

const BaseTeacher = obj({ students: array(string) });
const HasID = obj({ id: string });

const Teacher = BaseTeacher.merge(HasID);
type Teacher = Infer<typeof Teacher>; // => { students: string[], id: string }

If the two schemas share keys, the properties of B overrides the property of A. The returned schema also inherits the "unknownKeys" policy (strip/strict/passthrough) and the catchall schema of B.

.pick()/.omit()

You may use pick() and omit() to get a modified version of an object schema. Consider this Recipe schema:

const Recipe = obj({
  id: string,
  name: string,
  ingredients: array(string)
});

To only keep certain keys, use pick() .

const JustTheName = Recipe.pick(Recipe.k.name);
type JustTheName = Infer<typeof JustTheName>;
// => { name: string }

You could also just pass the property name directly.

const JustTheName = Recipe.pick('name');

There's also the getKeys() convenience function if you wish to use a property map. The keys of the passed object with be used and everything will be strongly typed.

const JustTheName = Recipe.pick(...getKeys({ id: 1, name: 1 }));

To remove certain keys, use omit() in the same fashion as pick()

const JustRecipeName = required(User, 'id', 'ingredients);
type JustRecipeName = Infer<typeof JustRecipeName>;
// => { name: string }

//alternative ways of omitting properties by name
const JustRecipeName = omit(Recipe, Recipe.k.id, Recipe.k.ingredients);
const JustRecipeName = omit(Recipe, ...getKeys({id: 1, ingredients: 1}));

.partial()

Just like built-in TypeScript utility type Partial, the partial function makes all properties optional.

Starting from this object:

const User = obj({
  email: string
  username: string,
});
// { email: string; username: string }

We can create a partial version:

const partialUser = User.partial();
// { email?: string | undefined; username?: string | undefined }

You can also specify which properties to make optional:

const optionalEmail = User.partial(User.k.email);

/*
{
  email?: string | undefined;
  username: string
}
*/

//alternative ways of picking propertis to the above
const optionalEmail = User.partial('email');
const optionalEmail = User.partial(...getKeys({email: 1}));

required()

Contrary to the partial function, the required function makes all properties required.

Starting from this object:

const User = obj({
  email: string.opt()
  username: string.opt(),
});
// { email?: string | undefined; username?: string | undefined }

We can create a required version:

const requiredUser = User.required();
// { email: string; username: string }

You can also specify which properties to make required:

const requiredEmail = User.required(User.k.email);
/*
{
  email: string;
  username?: string | undefined;
}
*/

//alternative ways of picking properties by name
const requiredEmail = User.required('email');
const requiredEmail = User.required(...getKeys({email: 1}));

.passthrough()

By default object schemas strip out unrecognized keys during parsing.

const person = obj({
  name: string
});

person.parse({
  name: 'bob dylan',
  extraKey: 61
});
// => { name: "bob dylan" }
// extraKey has been stripped

Instead, if you want to pass through unknown keys, use .passthrough() .

person.passthrough().parse({
  name: 'bob dylan',
  extraKey: 61
});
// => { name: "bob dylan", extraKey: 61 }

.strict

By default object schemas strip out unrecognized keys during parsing. You can disallow unknown keys with .strict() . If there are any unknown keys in the input, the library will throw an error.

const person = obj({
  name: string
}).strict();

person.parse({
  name: 'bob dylan',
  extraKey: 61
});
// => throws ZodError

.strip()

You can use the .strip method to reset an object schema to the default behavior (stripping unrecognized keys).

.catchall()

You can pass a "catchall" schema into an object schema. All unknown keys will be validated against it.

const person = obj({
  name: string
}).catchall(number);

person.parse({
  name: 'bob dylan',
  validExtraKey: 61 // works fine
});

person.parse({
  name: 'bob dylan',
  validExtraKey: false // fails
});
// => throws error

Using .catchall() obviates .passthrough() , .strip() , or .strict(). All keys are now considered "known".

Type methods

All types contain certain methods.

.parse()

.parse(data: unknown): T

Given any type, you can call its .parse method to check data is valid. If it is, a value is returned with full type information! Otherwise, an error is thrown.

IMPORTANT: The value returned by .parse will be almost always be a clone of the object you pass in.

const stringSchema = string;

stringSchema.parse('fish'); // => returns "fish"
stringSchema.parse(12); // throws error

.safeParse()

.safeParse(data:unknown): { success: true; data: T; } | { success: false; error: ParseError; }

If you don't want to throw errors when validation fails, use .safeParse. This method returns an object containing either the successfully parsed data or a ParseError instance containing specific information about the error.

string.safeParse(12);
// => { success: false; error: invalidTypeError }

string.safeParse('billie');
// => { success: true; data: 'billie' }

The result is a discriminated union, so you can handle errors very conveniently:

const result = string.safeParse('billie');
if (!result.success) {
  // handle error then return
  result.error;
} else {
  // do something
  result.data;
}

.where()

.where(condition: (value: T) => boolean, customError?: string | ((value: T) => ParseError | string))

where() lets you provide custom validation logic. You can return a more specific error message by returning a string, or providing a function that returns a string, or a rich ParseError object that you can customize.

const myNumber = number.where(x => x > 100);
myNumber.parse(150); // => 150
myNumber.parse(80); // => throws error

Simple error message:

const nonEmptyString = string.where(x => x !== '', 'string should not be empty);
myNumber.parse('nice'); // => nice
myNumber.parse(''); // => throws error

using the value for your error

const nonEmptyString = string.where(
  x => x !== '',
  value => `string: ${value} should not be empty`
);
myNumber.parse('nice'); // => nice
myNumber.parse(''); // => throws error

.tweak()

To transform data after parsing, but not change its type:

const stringWithWorld = string.tweak(x => x + ' world');

stringWithWorld.parse('hello'); // => "6"

.default()

As a convenience, you can provide a default value to be used if the input is undefined.

const stringWithDefault = string.default('tuna');

stringWithDefault.parse(undefined); // => "tuna"

Optionally, you can pass a function into .default that will be re-executed whenever a default value needs to be generated:

const numberWithRandomDefault = number.default(Math.random);

numberWithRandomDefault.parse(undefined); // => 0.4413456736055323
numberWithRandomDefault.parse(undefined); // => 0.1871840107401901
numberWithRandomDefault.parse(undefined); // => 0.7223408162401552

.catch()

Use .catch() to provide a "catch value" to be returned in the event of a parsing error.

const numberWithCatch = number.catch(42);

numberWithCatch.parse(5); // => 5
numberWithCatch.parse('tuna'); // => 42

Optionally, you can pass a function into .catch that will be re-executed whenever a default value needs to be generated. The caught error will be passed into this function.

const numberWithRandomCatch = number.catch(error => Math.random());

numberWithRandomCatch.parse('sup'); // => 0.4413456736055323
numberWithRandomCatch.parse('sup'); // => 0.1871840107401901
numberWithRandomCatch.parse('sup'); // => 0.7223408162401552

.opt()

You can make any schema by calling .opt() on any type.

const stringOrUndefined = string.opt();
type A = Infer<typeof stringOrUndefined>; // string | undefined;

const user = obj({
  username: string.opt
});
type C = Infer<typeof user>; // { username?: string | undefined };

opt() is simply a shorthand for creating a union of a type with the undef type and they are exactly equivalent.

const stringOrUndefined = union(string, undef);
type A = Infer<typeof stringOrUndefined>; // string | undefined;

You can remove undef and nul from an optional / union by calling unwrap().

const optionalString = string.opt();
optionalString.unwrap() === stringSchema; // true

NOTE: unwrap() is a shallow operation on a union as you can have nested unions. You can use flatExcludeKinds() if you wish to remove a type from all nested unions.

const optionalString = string.opt().opt();
optionalString.unwrap() === stringSchema; // false!
optionalString.unwrap().unwrap() === stringSchema; // true!

exclude(optionalString, undef) === stringSchema; // false!
flatExcludeKinds(optionalString, undef) === stringSchema; // true!

.nullable()

Similarly, you can create nullable types with nullable(). Again, this is a union of a type with nul

const nullableString = string.nullable();
nullableString.parse('asdf'); // => "asdf"
nullableString.parse(null); // => null

.nullish()

You can create types that are both optional and nullable with nullish(). Again, this is a union of a type with nul and undef

const nullishString = string.nullish();
nullishString.parse('asdf'); // => "asdf"
nullishString.parse(undefined); // => undefined
nullishString.parse(null); // => null

Reflection

.kind

Use .kind to get the broad category of a type. kind is always a string literal.

string.kind; // => 'string'
date.kind; // => 'date'
obj({ property: string }).kind; // => 'object'
array(number).kind; // => 'array'
literal('hello').kind; // => 'literal'
union(string, number).kind; // => 'union'

.name / withName()

All types can be named. This is for the users benefit. It does not enforce any typeing whatsoever

const Dog = obj({
  name: string,
  age: number
}).withName('Dog');

Dog.name; // => 'Dog'

Literals

Literal schemas represent a literal type, like "hello world" or 5.

const tuna = literal('tuna');
const twelve = literal(12);

const twobig = literal(2n); // bigint literal
const tru = literal(true);

const terrificSymbol = Symbol('terrific');
const terrific = literal(terrificSymbol);

// retrieve literal value
tuna.value; // "tuna"

Enums

const FishEnum = enumm(['Salmon', 'Tuna', 'Trout']);
type FishEnum = Infer<typeof FishEnum>;
// 'Salmon' | 'Tuna' | 'Trout'

enumm is a way to declare a schema with a fixed set of allowable string values. Pass the array of values directly into enumm(). Alternatively, use as const to define your enum values as a tuple of strings. See the const assertion docs for details.

const VALUES = ['Salmon', 'Tuna', 'Trout'] as const;
const FishEnum = enumm(VALUES);

This is not allowed, since TypeScript isn't able to infer the exact values of each element.

const fish = ['Salmon', 'Tuna', 'Trout'];
const FishEnum = enum(fish);

Autocompletion

To get autocompletion with an enumm type, use the .enum property of your schema:

FishEnum.enum.Salmon; // => autocompletes

FishEnum.enum;
/*
=> {
  Salmon: "Salmon",
  Tuna: "Tuna",
  Trout: "Trout",
}
*/

You can also retrieve the list of options as a tuple with the .shape property:

FishEnum.options; // ["Salmon", "Tuna", "Trout"];

Native enums

Enums created with enumm() are the recommended approach to defining and validating enums. But if you need to validate against an enum from a third-party library (or you don't want to rewrite your existing enums) you can use nativeEnum().

Numeric enums

enum Fruits {
  Apple,
  Banana
}

const FruitEnum = nativeEnum(Fruits);
type FruitEnum = Infer<typeof FruitEnum>; // Fruits

FruitEnum.parse(Fruits.Apple); // passes
FruitEnum.parse(Fruits.Banana); // passes
FruitEnum.parse(0); // passes
FruitEnum.parse(1); // passes
FruitEnum.parse(3); // fails

String enums

enum Fruits {
  Apple = 'apple',
  Banana = 'banana',
  Cantaloupe // you can mix numerical and string enums
}

const FruitEnum = nativeEnum(Fruits);
type FruitEnum = Infer<typeof FruitEnum>; // Fruits

FruitEnum.parse(Fruits.Apple); // passes
FruitEnum.parse(Fruits.Cantaloupe); // passes
FruitEnum.parse('apple'); // passes
FruitEnum.parse('banana'); // passes
FruitEnum.parse(0); // passes
FruitEnum.parse('Cantaloupe'); // fails

Const enums

The .nativeEnum() function works for as const objects as well. ⚠️ as const requires TypeScript 3.4+!

const Fruits = {
  Apple: 'apple',
  Banana: 'banana',
  Cantaloupe: 3
} as const;

const FruitEnum = nativeEnum(Fruits);
type FruitEnum = Infer<typeof FruitEnum>; // "apple" | "banana" | 3

FruitEnum.parse('apple'); // passes
FruitEnum.parse('banana'); // passes
FruitEnum.parse(3); // passes
FruitEnum.parse('Cantaloupe'); // fails

You can access the underlying object with the .enum property:

FruitEnum.enum.Apple; // "apple"

Arrays

const stringArray = array(string);

string.array.parse(['hi', 'there']); // => ['hi', 'there']
string.array.parse(['hi', 1]); // => fail

Use .element to access the schema for an element of the array.

stringArray.element; // => string schema

Unions

The union() method is for composing "OR" types. In Nimbit, unions are much richer than other libaries as they support reflection and the

const stringOrNumber = union(string, number);

stringOrNumber.parse('foo'); // passes
stringOrNumber.parse(14); // passes

The library will test the input against each of the "options" in order and return the first value that validates successfully.

Optional string validation:

To validate an optional form input, you can union the desired string validation with an empty string literal.

This example validates an input that is optional but needs to contain a valid URL:

const isUrl = /*...*/
const optionalUrl = union(string.where(isUrl).nullish(), literal(''));

optionalUrl.parse(undefined).success; // true
optionalUrl.parse(null).success; // true
optionalUrl.parse('').success; // true
optionalUrl.parse('https://github.com').success; // true
optionalUrl.parse('not a valid url').success; // false

Reflecting on a union:

Unions can be nested, so there is a .members property on union schemas that returns a strongly typed tuple of the union nesting hiearchy

const nestedUnions = union(string, union(number, undef));

nestedUnions.members; // => [StringT, [UnionType<..., [NumberT, UndefinedT]>]
nestedUnions.members[1].members; // => [NumberT, UndefinedT]
nestedUnions.members[1].members[0].kind; // => 'number'

Tuples

Unlike arrays, tuples have a fixed number of elements and each element can have a different type.

const athleteSchema = tuple([
  string, // name
  number, // jersey number
  obj({
    pointsScored: number
  }) // statistics
]);

type Athlete = Infer<typeof athleteSchema>;
// type Athlete = [string, number, { pointsScored: number }]

Records

Record schemas are used to validate types such as { [k: string]: number }.

If you want to validate the values of an object against some schema but don't care about the keys, use record(any, valueType):

const NumberCache = record(string, number);

type NumberCache = Infer<typeof NumberCache>;
// => { [k: string]: number }

This is particularly useful for storing or caching items by ID.

const userStore: UserStore = {};

userStore['77d2586b-9e8e-4ecf-8b21-ea7e0530eadd'] = {
  name: 'Carlotta'
}; // passes

userStore['77d2586b-9e8e-4ecf-8b21-ea7e0530eadd'] = {
  whatever: 'Ice cream sundae'
}; // TypeError

You can validate both the keys and the values

const NoEmptyKeysSchema = record(
  string.where(x => x.length >= 1),
  number
);
NoEmptyKeysSchema.parse({ count: 1 }); // => { 'count': 1 }
NoEmptyKeysSchema.parse({ '': 1 }); // fails

Maps

const stringNumberMap = map(string, number);

type StringNumberMap = Infer<typeof stringNumberMap>;
// type StringNumberMap = Map<string, number>

Sets

const numberSet = set(number);
type NumberSet = Infer<typeof numberSet>;
// type NumberSet = Set<number>

Recursive Objects

One of the biggest advantages of Nimbit is a new approach to recursive types. In Nimbit, recursive types are defined as naturally as typical objects, but instead you first define your object's shape as a class before passing it to obj().

Unlike Zod, the TypeScript type is able to be inferred automatically, and there is no need to use lazy().

class CategoryDef {
  name = string;
  subcategories = array(obj(CategoryDef));
}
const Category = obj(CategoryDef);
type Category = Infer<typeof Category>;

Category.parse({
  name: 'People',
  subcategories: [
    {
      name: 'Politicians',
      subcategories: [
        {
          name: 'Presidents',
          subcategories: []
        }
      ]
    }
  ]
}); // passes

// after you've defined Category with obj(), you no longer have to refer to the class definition. Magic!
const categoryList = array(Category);

Mutually recursive types are also allowed.

class PersonDef {
  name = string;
  addresses? = array(obj(AddressDef)).opt(); //notice that the class property is marked with '?'
}

class AddressDef {
  resident? = obj(PersonDef).opt();
  street = string;
}

const Person = obj(PersonDef);
type Person = Infer<typeof Person>;

const Address = obj(AddressDef);
type Address = Infer<typeof Address>;

Person.parse({
  name: 'Bob',
  addresses: [
    {
      resident: { name: 'Bob' },
      street: '123 Main St.'
    }
  ]
}); //passes

Address.parse({
  resident: { name: 'Bob' },
  street: '123 Main St.'
}); //passes

Optional properties in class defintions

One caveat of using classes to define your object shemas is that you must mark the class properties with a ? like above if you wish it to remain optional in your TypeScript definition. Calling .opt() alone won't do the trick. This is because of TypeScript.

Other Recursive Types / JSON

Unfortunately, recursive unions, and records are not as straightforward. For that you use lazy() which lets you use a type as you define it. The only problem is that you must provide the TypeScript types manually.

If you want to validate any JSON value, you can use the snippet below. (It also has a trivial guard against prototype poisoning.)

import {
  array,
  boolean,
  EVIL_PROTO,
  failInvalidType,
  lazy,
  nul,
  number,
  pass,
  record,
  string,
  to,
  union,
  type Infer,
  type LazyType,
  type ParseResult
} from '.';

const Literals = union(string, number, boolean, nul);
type JsonLiterals = Infer<typeof Literals>;

export type json = JsonLiterals | json[] | { [key: string]: json };

const jsonSchema: LazyType<json> = lazy(() => union(Literals, array(jsonSchema), record(string, jsonSchema)));

export const json = to(jsonSchema, jsonParse);

export function jsonParse(x: string): ParseResult<json> {
  try {
    return pass(JSON.parse(x, (key, value) => (key === EVIL_PROTO ? undefined : value)));
  } catch (err) {
    return failInvalidType('json', x);
  }
}
json.parse('{"a":1}'); //passes and returns { a: 1 }

Error handling

Note: work in progress.

At the moment, there is a discriminated union called ParseError that is returned by .safeParse() and .parse() when an error occurs.

const result = obj({
  name: string()
}).safeParse({ name: 12 });

if (!result.success) {
  result.error;
  /* [
      {
        "kind": "invalid_type",
        "expected": "string",
        "actual": 12,              
      }
  ] */
}

visitErrors()

The errors are in a hiearchary of ParseError objects. You can use visitErrors to traverse the errors and format them as you wish.

const TestObj = t.obj({
  strProp: t.string,
  nested: t.obj({
    nestedA: t.number,
    nestedB: t.string
  })
});

const result = TestObj.safeParse({
  strProp: 1337,
  nested: {
    nestedA: 'wrong',
    nestedB: 7n
  }
});

if (!result.success) {
  for (const [error, path] of visitErrors(result.error)) {
    console.log(error.kind);
    console.log(error.path);
  }
}

TBD

Fluent interface extension, module augmentation

It may be possible and it is desirable for users to be able to augment the built-in types with their own convenience methods. This could be accomplished via class mix-ins or modifying the prototoypes with TypeScript's module augmentation. This is a big decision as there appear to be nuances with TypeScript module augmentation and globabally modifying a prototype may or may not potentially cause problems with multiple libraries modifying the same types.

Built-in validators

TBD: There are pros and cons to shipping with built-in validators like min(), nonempty etc like Zod has. In light of the fact that where() handles any possible scenario, and making a reusable validators is as simple as defining simple boolean expressions and making higher order functions.

const nonEmpty = (x: string) => x !== '' && string != null;
const min = (min: number) => (x: number) => x >= min;
const range = (min: number, max: number) => (x: number) => x >= min && x <= max;
const matches = (regex: RegExp) => (x: string) => regex.test(x);
const email = string.where(matches(/^([A-Z0-9_+-]+\.?)*[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i));

const formData = obj({
  name: string.where(nonEmpty),
  age: number.where(min(0)),
  quantity: number.where(range(1, 100)),
  mainEmail: email
});

formData.parse({
  name: '',
  age: -1,
  quantity: 0,
  mainEmail: 'bob@fcom'
});

Note that the regex above is not necessarily to spec, which is why not including it in validator libraries is worthy of consideration.

Cyclical objects

TBD: Add support in parsing

Promises

TBD whether to support

Functions

TBD whether to support

Out of scope

Intersections

Interesections are not in scope for Nimbit. They have limited use in the real world, and cause immense complications in implementing a type system library. Even though they are present in other libraries like Zod, they end up being somewhat of a dead end as you lose the ability to use pick and omit.

In general, use merge and extend.

Built-in validators

Interesections are not in scope for Nimbit. They have limited use in the real world, and cause immense complications in implementing a type system library. Even though they are present in other libraries like Zod, they end up being somewhat of a dead end as you lose the ability to use pick and omit.

In general, use merge and extend.

WIP

excludeKind()

Allows you to remove a kind of type from a single union. It does not traverse nested unions. This is presently in the library but has a slight chance of changing

flatExcludeKind()

Flattens the hierarchy of nested Unions and then removes a kind of type from all the unions. A new union is returned.

Extract / FlatExtract

Custom Types

TODO: document createType() and deriving from Typ after a round of feedback.

One of the "big deals" about Nimbit is how much of type declaration is pushed into userland so that there is maximum flexibility. For example, the library does not ship with the more esoteric JS types like Typed Arrays like Int8Array, Float32Array, etc. However, you can defined these types yourself through exactly the same mechanism that the library uses to define string, number, etc. This means that you never need to wait for this library to upgrade to use any types you come across.

Furthermore, this feature is also here in order to lay the groundwork for ORM's to define Database types in a semi-native fashion. Note: This is considered unstable until this exact scenario is tested.

.brand()

TBD

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published