Search


Search something to see results

Foreword: I use a domain-driven & type-driven approach to development. I write types before anything else.
Then I use them to define schemas, use-cases, etc…

All the libraries I will mention are very nice, usually with good type inference and large API surface, so I will stick to the differences.

In general, unhoax proposes less features, and that is because we all have our development environment and there is no way for me to know about your requirements.

Typically, I will never provide any email refinement/schema.

See why

The "email" concept may be extraordinarily different from one environment to another:

In some cases you will brand your email type: type Email = Branded<string, 'Email'>, in some other a simple string will do.

In some cases, you will use more accurate sub-types:

type DisguisedEmail = Branded<string, 'DisguisedEmail'> // ie: me+disguisement@gmail.com
type UniqueEmail = Branded<string, 'UniqueEmail'>
type Email = DisguisedEmail | UniqueEmail

// accept only non-disguised emails at sign up.
type SignUp = (email: UniqueEmail) =>

There is NO WAY I will know about any of that. It’s all up to you.

I can recommend using is-email though.

I took the same example as valibot’s announcement post

type Email = Branded<string, 'Email'>
type Password = Branded<string, 'Password'>

type LoginData = {
email: Email
password: Password
}

Bundle Size

NPM | Website | tree-shakeable

import * as x from 'unhoax'
import pipe from 'just-pipe' // pick your pkg
import isEmail from 'is-email' // pick your pkg

declare const isEmailGuard: (value: string) => value is Email

const emailSchema = pipe(
x.string,

x.refine('Email', isEmail),
x.map((value) => value as Email),
// equivalent to:
x.refineAs('Email', isEmailGuard),
)

const emailSchema: x.Schema<Email, unknown>
// Quite simple, isn't it?
// NB: `unknown` in `Schema<…, unknown>` is the input of the parse function.
// emailSchema.parse(input <- unknown)

const loginDataSchema = x.object<LoginData>({
email: emailSchema,
password: passwordSchema,
})
const loginDataSchema: x.ObjectSchema<LoginData, unknown>

const data = x.unsafeParse(loginDataSchema, { … })
const data: LoginData // 🙌

Bundle Size

NPM | Website | not tree-shakeable

They both use the same object-oriented approach, I will cover Zod only.

const emailSchema = z
.string()
.refine(isEmail)
.transform((value) => value as Email)
// equivalent to:
.refine(isEmailGuard)
satisfies z.Schema<Email, any, unknown>
// I get away with an `any`, which is not super satisfying

const loginDataSchema = z.object({
email: emailSchema,
password: passwordSchema,
}) satisfies z.Schema<LoginData, any, unknown>

const data = loginDataSchema.parse({ … })
// This what gets inferred instead of `LoginData` 🤮
const data: {
email: string & { [tag] … };
password: string & { [tag] … };
}

Bundle Size

NPM | Website | tree-shakeable

I love this library, tried it and got disappointed on one side only: I want a type-driven approach. If you don’t, then use valibot, really.

The type-driven approach is where I got stuck with valibot, the types are not straightforward at all – despite the library being excellent in general.

See for yourself, let’s see how to write the Email schema:

import * as v from 'valibot'

const emailSchema = v.pipe(
v.string(),
v.email(), // or v.check(isEmail),
v.transform((value) => value as Email),
)

const emailSchema: v.SchemaWithPipe<[
v.StringSchema<undefined>,
v.EmailAction<string, undefined>,
v.TransformAction<string, Email>,
]>
// Quite complex, isn’t it ?

The same goes for the LoginData schema:

const loginDataSchema = v.object({
email: emailSchema,
password: passwordSchema,
}) satisfies TypeToSatisfy

type TypeToSatisfy = v.ObjectSchema<LoginData, unknown> // fails
// fortunately for you I dug:
type TypeToSatisfy = v.BaseSchema<unknown, LoginData, any>
// … if you accept using any, otherwise:
type TypeToSatisfy = v.BaseSchema<
unknown,
LoginData,
v.BaseIssue<unknown>
>

const data = v.parse(loginDataSchema, { … })
// This what gets inferred instead of `LoginData` 🤮
const data: {
email: string & { [tag] … };
password: string & { [tag] … };
}

Bundle Size

NPM | not tree-shakeable

I did not know this library before writing this one, and quite frankly if I'd choose another it would be the one.

Things RunTypes has and unhoax does not:

  • Template literals
  • Function Contract – nice, but should not be part of a schema library IMO
  • Branding – nice, but should not be part of a schema library IMO
  • Pattern matching – nice, but should not be part of a schema library IMO
  • Various integrations with tools like json-schema, property-based testing, typing db schemas, and more – Create an issue if you ever want any of that.

Some things that may be missing: Transforming the output: x.map, z.transform, v.transform, …
It is a big deal to me because my code was super simple yet I had to change already

Beyond all this, this example is pretty much the same, I was happy-enough about it:

import * as r from 'runtypes'

const emailSchema = r.String.withGuard(isEmailGuard) satisfies r.Runtype<Email>
const passwordSchema = r.String.withGuard(isPasswordGuard) satisfies r.Runtype<Password>

const loginDataSchema = r.Record({
email: emailSchema,
password: passwordSchema,
}) satisfies r.Runtype<LoginData>

const data = loginDataSchema.check({ … })
// runtypes is waaay better than the others at inference:
const data: {
email: Email;
password: Password;
}

Bundle Size

NPM | Website | tree-shakeable

I have one tini-tiny problem with superstruct: either nested coercion is broken, either I did not get it. In both cases it is a problem for me.

Because there is no mapping/transforming mechanism, I have to resort to coercion.

I tried digging into it to issue a PR, and I stopped out of tiredness.

Maybe I got something wrong, anyway just by writing the example, I can say it is too complicated.

The broken piece:

import * as S from 'superstruct'

const TestName = S.coerce(
S.object({ value: S.string() }),
S.string(),
(name) => ({ value: name }),
)

S.mask('Test', TestName) // { value: 'Test' } ✅

const TestSchema = S.object({ name: TestName })
S.mask({ name: 'Test' }, TestSchema) // { name: { value: 'Test' } } ✅

const Test = S.coerce(
S.object({ nested: TestSchema }),
TestSchema,
(nested: { name: { value: string } }) => ({ nested }),
)

console.info(S.mask({ name: 'Jack' }, Test)) // throws
// I expected `name` to be coerced as `{ value: 'Jack' }`
// but it did not.