Why yet-another schema library?
TL;DR: Safety-first and terrible types.
Safety first
unhoax provides default size guards everywhere, to diminish the risk of Denial of Service attacks, resource exhaustion and oversized payload.
import { x } from 'unhoax'
x.array.defaultMaxSize // 500
x.setOf.defaultMaxSize // 500
x.mapOf.defaultMaxSize // 500
x.string.defaultMaxSize // 500
// You can define your own guards:
x.array.defaultMaxSize = 3
// every array schema with no specific max size
// will now have a maximum of 3 items.
// Beware, the safety nets are **not** retro-active, you should override them
// the entry point of your program.
const mySchema = x.array(x.number)
mySchema.parse([1, 2, 3, 4]).success === falseYou can deactivate those guards by providing the value Infinity:
x.array.defaultMaxSize = InfinityTerrible types
Most of the libraries out there have terrible typings, which is difficult for Type-Driven Development lovers.
All the libraries force you to adapt your types to them. A good library should integrate with you (or your types), not force you to do things for them, getting the best of all worlds.
This ends up making TypeScript intellisense and errors completely unreadable, it doesn't have to be that way.
import { z } from 'zod'
const userSchema = z.object({
id: z.number(),
name: z.string(),
})
type User = z.infer<typeof userSchema>
declare const getUser: (value: User) => void
// Hovering on `value` gives:
// (parameter) value: {
// id: number;
// name: string;
// }The value here drives me crazy. For 2 properties it is still fine, but for more, it quickly goes out of hand.
Let’s say you have a long process which relies on such a User type. At the end of your process (involving 5+ functions), you hover on a variable and see:
const someValue: { id: number; name: string }Does it help ? Not a bit. This could be a Tag, a Label, a short Account or User… Shortly, what a type contains is not what it means – or what it is. That‘s what naming conveys: valuable hints about what the code is doing.
A great way of ensuring proper names for types in TypeScript is to use interfaces:
interface User {
id: number
name: string
}
const userSchema = x.typed<User>().object({ … })
// x.ObjectSchema<User, TheInternalsGoHereAfterYourType>
declare const getUser: (value: User) => void
// Hovering on `value` gives:
// (parameter) value: UserAnother zod example
const userSchema = z.object({
id: z.number(),
firstName: z.string(),
lastName: z.string(),
email: z.string(),
prop1: z.string(),
// …
prop9: z.string(),
})
// When I hover, here is the type I get:
// I haven't even started anything, the type has already become unreadable.
const userSchema: z.ZodObject<{
id: z.ZodNumber;
firstName: z.ZodString;
lastName: z.ZodString;
email: z.ZodString;
prop1: z.ZodString;
prop2: z.ZodString;
prop3: z.ZodString;
prop4: z.ZodString;
... 4 more ...;
prop9: z.ZodString;
}, "strip", z.ZodTypeAny, {
...;
}, {
...;
}>Now imagine reading an error containing that type… where do you start? Some plugins like pretty TS errors help out, but still.
Plus, I usually already have my type and want to use it to get proper names, which I can’t:
interface User { … }
const userSchema = z.object<User>({ … })
// fails -> Type `User` does not satisfy the constraint `ZodRawShape`A valibot example
const userSchema = v.object({
id: v.number(),
name: v.string(),
})
// hovering on `userSchema` gives:
const userSchema: v.ObjectSchema<
{
readonly id: v.NumberSchema<undefined>
readonly name: v.StringSchema<undefined>
},
undefined
>
// With 2 properties it is fine, but I will have more.
// And I haven't even transformed the output yet (those who know… they know).
interface User { … }
// If I try to give it an interface
// It fails -> Type `User` does not satisfy the constraint `ObjectEntries`
const userSchema = v.object<User>({ … })… and so on
This applies for effect, decoders, @arrirpc/schema, etc…
When working on a production application, it means I have no choice but having doomed unreadable types. Needless to say it does not help my daily life.
The only nice library I have seen regarding the type system is ts.data.json. unhoax brings the same goodies and a lot more utilities and safety-by-default for the same bundle size (~5kB).
What about ArkType, ReScript Schema & co?
They compile schemas instead of parsing at runtime. Which tends to delegate bundle size on you instead of the library and requires a compile step, while JavaScript is an interpreted language.
NodeJS now supports natively TypeScript by stripping type annotations (transpiling), not compiling the code. Transpiling is in general a common practice, so I'd rather avoid making a compile step necessary.
Use those if you absolutely need a lightning-fast super-quick library because your environment has some response time specificities. I would redirect you to the runtime benchmarks to pick your best option, and get prepared to facade to interchange it as soon as a faster lib comes out.
In other cases, libraries can leverage unsafe APIs like new Function or eval to compile schemas.
MDN excerpts
- The
Function()constructor creates Function objects. Calling the constructor directly can create functions dynamically, but suffers from security and similar (but far less significant) performance issues aseval().- Executing JavaScript from a string is an enormous security risk. It is far too easy for a bad actor to run arbitrary code when you use
eval(). See Never use directeval()!, below.