Migrating from Zod
Valdix is API-compatible with most of Zod. The migration is mostly mechanical.
Imports
// Zod
import { z } from "zod";
// Valdix
import v from "@denisetiya/valdix";
Schema construction
// Zod
const User = z.object({
name: z.string().min(3),
email: z.string().email(),
age: z.number().int().min(0),
});
// Valdix: identical
const User = v.object({
name: v.string().min(3),
email: v.string().email(),
age: v.number().int().min(0),
});
Type inference
// Zod
type User = z.infer<typeof User>;
// Valdix
type User = v.Infer<typeof User>;
safeParse output
// Zod
const r = User.safeParse(input);
if (!r.success) {
r.error.issues.forEach((i) => {
console.log(i.path, i.message, i.code);
});
}
// Valdix: flatter, ready for UI
const r = User.safeParse(input);
if (!r.success) {
r.errors.forEach((e) => {
console.log(e.path, e.message, e.code, e.field);
});
}
ValdixError (thrown by .parse()) has the same issues array as r.errors.
Multi-language
The big difference. Valdix errors are localized and casual:
v.useLang("id");
const r = User.safeParse({ name: "E", email: "bad", age: -1 });
// r.errors[0] → { field: "name", message: "Minimal 1 karakter ya", code: "too_small" }
Zod has no built-in i18n. You typically write a custom error map (Zod v3.20+):
z.setErrorMap((issue, ctx) => {
return "...";
});
Valdix ships 17 locales (en/id/jp/zh/zh-TW/ko/fr/pt/nl/es/de/ar/ru/it/vi/hi/th) out of the box. Add your own with v.registerLocale(lang, catalog).
Custom messages
// Zod
z.string().min(3, { message: "Minimal 3 karakter" });
// Valdix
v.string().min(3, "Minimal 3 karakter");
Valdix supports {{field}} interpolation:
v.object({
userName: v.string().min(3, "{{field}} minimal 3"),
});
// → "user name minimal 3"
Async
// Zod
const r = await User.parseAsync(input);
// Valdix
const r = await User.parseAsync(input);
// or
const r = await User.safeParseAsync(input);
.superRefine is async-friendly in both.
Differences to watch
| Feature | Zod | Valdix |
|---|---|---|
| Custom error code at parse | z.NEVER, z.never() |
v.never() |
| Branded types | z.string().brand<"UserId">() |
v.string().brand("UserId") |
passthrough |
z.object({...}).passthrough() |
.passthrough() (default is strip) |
| Default behavior | strip |
strip |
.refine returns |
boolean | { message, path? } |
boolean | string | IssueInput |
.discriminatedUnion |
z.discriminatedUnion("type", {...}) |
v.discriminatedUnion("type", {...}) (no array form yet) |
z.stringIP(), etc. |
Yes | v.string().ip() |
z.coerce.* |
Yes | v.coerce.* |
z.function() |
Yes | v.function() |
z.lazy() |
Yes | v.lazy() |
z.preprocess() |
Yes | v.preprocess() |
| JSON Schema export | Via zod-to-json-schema |
.toJSONSchema() (built-in) |
| Standard Schema | z.standard() |
schema["~standard"]() |
| Bundle size | ~10 KB gzip | ~1 KB gzip |
Performance
Recent stable benchmarks (200k iterations, averaged across 5 runs, vs Zod 3.x):
| case | Valdix | Zod | ratio |
|---|---|---|---|
| string | 3.6M ops/s | 3.8M ops/s | about tied |
| 4.0M ops/s | 3.2M ops/s | 1.2-1.8x faster | |
| number | 6.6M ops/s | 5.0M ops/s | 1.2-1.5x faster |
| object | 1.1M ops/s | 1.2M ops/s | about tied |
| array | 3.7M ops/s | 1.5M ops/s | 2.5-4.0x faster |
| deep | 770k ops/s | 540k ops/s | 1.3-1.5x faster |
If raw throughput matters more than bundle size, run the comparison yourself:
cd /tmp/bench && npm i zod@3
node /path/to/valdix/benchmark/compare.mjs