I highly recommend reading this with full Typescript support in the code-snippets
As with other validation libaries like zod
, yup
, io-ts
, superstruct
, valibot
, etc...
You can easily validate any value using:
import { object, string, number } from '@robolex/sure'
const validator = object({
name: string,
age: number,
})
What this library does differently is that it puts the focus on customization and makes the core so simple,
that you can define your string
validator like this:
const string = (value: unknown) => {
if (typeof value === 'string') return [true, value] as const
return [false, 'not string'] as const
}
This is actually how the string
from the initial example is defined.
I've written a lengthy article about the problems I've encountered while trying to validate a simple form, and not wanting to get forced into some custom strane DSL.
It's a good read, I promise.
The gist of it is that validating a value as string
or number
is easy in any of the aforementioned libraries.
What's not easy, is validating an IBAN using validator.js
, or validating an IBAN, but showing different error messages for countries whose IBAN your
SaaS doesn't currently support, but will support in the next quarter.
If you want to do this in zod
, you'll have to understand the difference between
refine
https://zod.dev/?id=refine
superRefine
https://zod.dev/?id=superrefine
transform
https://zod.dev/?id=transform
You'll also encounters lots of interesting issues related to how these 3 methods combine with each other.
The type of which type of "errors" you can receive is usually not enforced.
This library takes a different approach and considers errors as first class citizens.
That was the main reason the function bad
isn't called error
since it's not something I like to throw, it's something I want to know and understand.
./esm/core.js
export function sure(insure, meta) {
return Object.assign(insure, { meta })
}
export function pure(insure) {
return insure
}
export function bad(val) {
return [false, val]
}
export function good(val) {
return [true, val]
}
Everything else is based on the core types and those 4 functions.
If you want to validate that something is an IBAN you can just define a function:
import { pure, sure, bad, good, Sure, InferGood, InferBad } from '@robolex/sure'
import { isIBAN } from 'validator'
const ibanSchema = pure(value => {
if (typeof value === 'string' && isIBAN(value)) return good(value)
return bad('not an IBAN')
})
// if you don't want to use the utility function you can do it like this:
const ibanSchema2 = (value => {
if (typeof value === 'string' && isIBAN(value)) return [true, value] as const
return [false, 'not an IBAN'] as const
}) satisfies Sure
// The `satisfies` is not strictly necessary, but it provides the type-guarantee that
// your function holds all the necessary requirements to be considered a schema
type InferredGoodIban = InferGood<typeof ibanSchema2>
type InferredBadIban = InferBad<typeof ibanSchema2>
Of course, there are the basic utilities, for things you'd expect from other type-safe libraries:
/packages/sure/esm/primitives.js
The library doesn't provide too many primitives, the idea being, that you already know how to check if something is a string.
Other libraries have lots of different views about what a string is (empty or not), or what a number is (NaN or infinity).
Usually I want to validate if something is a positive integer, or if something is a valid age. In that case I just write a function, that's all.
Nevertheless, there are currently several primitives:
import { string, number, boolean, nil, undef, unknown } from '@robolex/sure'
import type { InferGood } from '@robolex/sure'
type InferString = InferGood<typeof string>
type InferNumber = InferGood<typeof number>
type InferBoolean = InferGood<typeof boolean>
type InferNil = InferGood<typeof nil>
type InferUndef = InferGood<typeof undef>
type InferUnknown = InferGood<typeof unknown>
import { object, optional, string, number } from '@robolex/sure'
import type { InferGood } from '@robolex/sure'
const validator = object({
name: string,
age: optional(number),
})
// The `optional` is a real optional if you use `exactOptionalPropertyTypes`
// It's not a `number | undefined`
/*
type GoodValue = {
age?: number;
name: string;
}
*/
type GoodValue = InferGood<typeof validator>
import { array, string } from '@robolex/sure'
const validator = array(string)
/*
type GoodValue = string[]
*/
type GoodValue = InferGood<typeof validator>
This is the refine
function from zod
, but it's much simpler to use.
It runs the first validator, and if it's successful, it runs the second validator.
It returns the first bad value it encounters.
import { after, string, number, bad, good, InferGood, InferBad } from '@robolex/sure'
import { isIBAN } from 'validator'
const ibanSchema = after(string, val => {
// `val` is already inferred as a `string`
if (isIBAN(val)) return good(val)
return bad('not iban')
})
/*
type InferredGood = string
*/
type InferredGood = InferGood<typeof ibanSchema>
/*
type InferedBad = "not string" | "not iban"
*/
type InferedBad = InferBad<typeof ibanSchema>
import { tuple, string, number, InferGood } from '@robolex/sure'
const validator = tuple([string, number])
/*
type GoodValue = [string, number]
*/
type GoodValue = InferGood<typeof validator>
import { literal, string, InferGood } from '@robolex/sure'
const validator = literal('hello')
/*
type GoodValue = "hello"
*/
type GoodValue = InferGood<typeof validator>
import { or, string, number, undef, InferGood } from '@robolex/sure'
const maybeString = or(string, undef)
/*
type GoodValue = string | undefined
*/
type GoodValue = InferGood<typeof maybeString>
/packages/sure/esm/intersection.js
Currently and
(which is a different name for intersection
) works only on objects, since I use object destructuring when returning
the final value.
import { and, object, string, number, InferGood } from '@robolex/sure'
const simple = and(
object({
name: string,
}),
object({
age: number,
})
)
/*
type GoodValue = {
name: string;
} & {
age: number;
}
*/
type GoodValue = InferGood<typeof simple>
The recursive
function is a bit more complex, basically, you have an object and you can say that one of the fields is expected to be recursive.
Afterwards you can use the recurse
function to define the shape of the recursive element.
The idea here is that it's not possible to know the shape of the recursive element before you define the object that contains it.
This was implemented mostly to test the limits of the library.
import { object, string, array, recurse, recursiveElem } from '@robolex/sure'
import type { InferGood } from '@robolex/sure'
const baseObj = object({
name: string,
children: recursiveElem,
})
const recurseSure = recurse(baseObj, recurseSure => {
return array(recurseSure)
})
/*
type GoodValue = {
name: string;
children: {
name: string;
children: typeof RecurseSymbol;
}[];
}
*/
type GoodValue = InferGood<typeof recurseSure>
Things like object
that return another function which is the actual validator, can have data that's attached to them.
At the moment I don't personally use this feature.
They were added to allow introspection of the validation schema in cases where it might be necessary to
Decided how to store any metadata was a tought decision, especially when validators are simple functions.
The meta
is a property that can be directly set to a function.
export type MetaNever = { meta?: never }
export type MetaObj<TMeta = unknown> = { meta: TMeta }
This type tells us that a function can either NOT have a meta
property, or it can have meta
property that you don't know the type of.
This idea can be applied to any function whatsoever, since any function can either have a meta
property or not. Most functions don't.
This seemed like the less invasive option.