A schema library that does a lot more, by doing strictly less.
This library exploits a TypeScript feature called
inferred type predicates
to do what libaries like zod
do, natively.
tl;dr: The schemas in
@traversable/schema
aren't schemas: they're just functions that return true or false.
import { t } from '@traversable/schema'
declare let ex_01: unknown
if (t.bigint(ex_01)) {
ex_01
// ^? const ex_01: bigint
}
Predicates, and functions that accept predicates, and return predicates:
import { t } from '@traversable/schema'
declare let ex_01: unknown
if (t.object({ a: t.optional(t.number), b: t.union(t.boolean, t.null) })) {
ex_01
// ^? const ex_01: { a?: number, b: boolean | null }
}
Since TypeScript v5.5, type narrowing "flows through" inline predicates. So
you don't really need zod
to get type narrowing to work in userland anymore.
That doesn't mean you don't need a schema library anymore, since a schema can
go places that TypeScript can't. Libraries like react-hook-form
use the schema
you provide and adapt their behavior accordingly.
If you've ever used a library that does this, then you know how magical it feels.
Libraries like zod
are an important part of a developer's toolkit.
So what makes @traversable/schema
different?
Like, really tiny. Even libraries like valibot
seem enormous in comparison.
But will you miss some of the gadgets that come with pre-5.5 libraries?
Out of the box, @traversable/schema
ships the usual suspects:
t.object
t.array
t.record
t.tuple
t.string
t.number
t.bigint
t.null
t.undefined
t.symbol
t.void
t.never
t.unknown
t.any
Importantly, all schemas behave identically to the version you're used to.
And by identically, we mean exactly that: our test suite uses the same library
that jest
uses internally to test their own assertions (fast-check
). The
strategy is simple:
- we use a seed value to generate an arbitrary
@traversable/schema
schema - we use the same seed to generate the correlating
zod
schema - we fuzz test them both, generating random data, and making sure we get the same result in every case
- repeat 1000s of times for PR we stand up against main
It took a lot of work to get there, but taking this approach undercovered dozens of corner cases. Without it, it would have taken years of user-reported bugs to get to the same level of reliability.
Using @traversable/schema
is intuitive, because there's really not much to it.
You can pick the schemas you need off the shelf, or you can write the components
yourself, and stitch them together with a few t.object
s or t.array
s.
Here's what that might look like in practice:
import { t } from '@traversable/schema'
const territoryProps = {
}
const AddressSchema = t.object({
street_1: t.string,
street_2: t.optional(t.string),
state: t.memberOf('AK', 'AL', 'AZ', 'AR', 'CA', 'CO', 'CT' /* , ... */),
city: t.string,
postal_code: t.refine(
(x) => typeof x === 'string',
(x) => /^\d{5}?$/.test(x),
),
})
// Hovering over `AddressSchema`, we see:
const AddressSchema: t.object<{
street_1: t.string;
street_2: t.optional<t.string>;
state: t.memberOf<["AK", "AL", "AZ", "AR", "CA", "CO", "CT"]>;
city: t.string;
postal_code: t.inline<string>;
}>
// Let's infer the target type of our schema:
type Address = t.typeof<typeof AddressSchema>
// Hovering over `Address`, we see:
type Address = {
street_1: string;
street_2?: string;
state: "AK" | "AL" | "AZ" | "AR" | "CA" | "CO" | "CT";
city: string;
postal_code: string;
}
Of course, nothing is free, so there is a tradeoff:
If you need something specific to your use case, currently (since there is not an ecosystem), you'll have to build it yourself.
That said, there's plenty of upside.
By removing the unnecessary layer of indirection, we remove the need for expensive overrides, or fancy recursive types.
Again, it's worth repeating:
The magic here is that there is no magic. TypeScript does its thing, and we just get out of the way.
** In some cases required us to add
options (like treatUndefinedAndOptionalAsTheSame
) to support both.
flowchart TD
registry(@traversable/registry)
json(@traversable/json) -.-> registry(@traversable/registry)
schema-core(@traversable/schema-core) -.-> json(@traversable/json)
schema-core(@traversable/schema-core) -.-> registry(@traversable/registry)
schema-valibot-adapter(@traversable/schema-valibot-adapter) -.-> json(@traversable/json)
schema-valibot-adapter(@traversable/schema-valibot-adapter) -.-> registry(@traversable/registry)
schema-zod-adapter(@traversable/schema-zod-adapter) -.-> json(@traversable/json)
schema-zod-adapter(@traversable/schema-zod-adapter) -.-> registry(@traversable/registry)
schema-seed(@traversable/schema-seed) -.-> json(@traversable/json)
schema-seed(@traversable/schema-seed) -.-> registry(@traversable/registry)
schema-seed(@traversable/schema-seed) -.-> schema-core(@traversable/schema-core)
schema(@traversable/schema) -.-> json(@traversable/json)
schema(@traversable/schema) -.-> schema-core(@traversable/schema-core)
schema(@traversable/schema) -.-> schema-seed(@traversable/schema-seed)
schema(@traversable/schema) -.-> schema-zod-adapter(@traversable/schema-zod-adapter)
schema(@traversable/schema) -.depends on.-> registry(@traversable/registry)