Skip to content

Monorepo of packages that we used privately and decided to publish. Currently contains `@robolex/sure` and `@robolex/dependent`, also some eslint/ts configs

Notifications You must be signed in to change notification settings

robolex-app/public_ts

Repository files navigation

I highly recommend reading this with full Typescript support in the code-snippets

tsx.md/github.com/robolex-app/public_ts/blob/main/README.md

Headless Type-safe validation library for Javascript

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.

Why a new library?

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.

tsx.md/blog/valid_form

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.

Type for the errors

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.

The core library in this:

./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.

Custom validators:

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>

Common utilities:

Of course, there are the basic utilities, for things you'd expect from other type-safe libraries:

primitives

/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>

object and optional

/packages/sure/esm/object.js

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>

array

/packages/sure/esm/array.js

import { array, string } from '@robolex/sure'

const validator = array(string)

/*
type GoodValue = string[]
*/
type GoodValue = InferGood<typeof validator>

after

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>

Other utilities

tuple

/packages/sure/esm/tuple.js

import { tuple, string, number, InferGood } from '@robolex/sure'

const validator = tuple([string, number])

/*
type GoodValue = [string, number]
*/
type GoodValue = InferGood<typeof validator>

literal

/packages/sure/esm/literal.js

import { literal, string, InferGood } from '@robolex/sure'

const validator = literal('hello')

/*
type GoodValue = "hello"
*/
type GoodValue = InferGood<typeof validator>

union = or

/packages/sure/esm/union.js

import { or, string, number, undef, InferGood } from '@robolex/sure'

const maybeString = or(string, undef)

/*
type GoodValue = string | undefined
*/
type GoodValue = InferGood<typeof maybeString>

Advanced utilities

intersection = and

/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>

recursive

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>

The meta property in the validation function

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.

About

Monorepo of packages that we used privately and decided to publish. Currently contains `@robolex/sure` and `@robolex/dependent`, also some eslint/ts configs

Resources

Stars

Watchers

Forks

Packages

No packages published