@kmacute/vin-validation
v0.3.10
Published
Laravel-style validation with TypeScript inference. The single source of truth for runtime validation, type inference, transforms, defaults, and async validation.
Maintainers
Readme
@kmacute/vin-validation — vin
A production-ready TypeScript validation library for Node.js and TanStack Start that feels like Laravel for developers while delivering Zod-level type inference.
- Laravel's validation developer experience
- Laravel's error response format
- Zod's schema-first philosophy
- TypeScript's strong type inference
- No runtime dependency on Zod, Valibot, Yup, Joi, Ajv, or any other validation library — everything is implemented from scratch
- First-class Drizzle ORM adapter for
unique/existschecks - Tree-shakable, fully typed, exhaustively tested
Table of Contents
- Installation
- Quick Start
- The
vNamespace - Parsing API
- Type Inference
- Required, Nullable, Optional
- Validation Rules Reference
- Polymorphic
min/max/between/size - Array Validation
- Array Cross-Field Validation
- Array Conditional Rules
- Cross-Field Validation
- Conditional Rules
- Transformations
- Custom Validation
- Async Validation (Drizzle ORM)
- Custom Async Adapter (Prisma, Kysely, raw SQL)
- Error Format
- Real-World Examples
- Performance
- Architecture
Installation
# Core
npm install @kmacute/vin-validation
# or
pnpm add @kmacute/vin-validationIf you use Drizzle ORM and want the built-in adapter:
npm install @kmacute/vin-validation drizzle-orm
# drizzle-orm is an optional peer dependencySupported drizzle-orm versions: ^0.30 → ^1.0 (any dialect: pg, mysql, sqlite; any driver).
Quick Start
import { v, type Infer } from "@kmacute/vin-validation";
const UserSchema = v.object({
id: v.uuid(),
username: v.string().required().min(3).max(50),
email: v.email().required(),
password: v.string().required().min(8).confirmed(),
age: v.number().min(18),
roles: v.array(v.enum(["admin", "staff"])).min(1),
profile: v.object({
firstName: v.string(),
lastName: v.string(),
}),
items: v.array({
productId: v.uuid(),
quantity: v.number().required().min(1),
remarks: v.string().nullable(),
}),
remarks: v.string().nullable(),
});
type User = Infer<typeof UserSchema>;
const result = UserSchema.safeParse(input);
if (result.success) {
console.log(result.data); // typed as User
} else {
console.log(result.error.toJSON());
// {
// message: "The given data was invalid.",
// errors: {
// username: ["The username field is required."],
// "items.0.quantity": ["The quantity must be at least 1."]
// }
// }
}Notice v.array({ ... }) is shorthand for v.array(v.object({ ... })).
The v Namespace
v is a Proxy exposing every primitive constructor:
| Constructor | Returns | Inherits / Notes |
| ---------------------- | -------------------- | ------------------------------------------------- |
| v.string() | StringSchema | base for email / uuid / url |
| v.number() | NumberSchema | base for integer |
| v.integer() | IntegerSchema | extends NumberSchema |
| v.boolean() | BooleanSchema | |
| v.bigint() | BigIntSchema | |
| v.date() | DateSchema | coerces strings/numbers → Date |
| v.datetime() | DateTimeSchema | extends DateSchema |
| v.email() | EmailSchema | extends StringSchema |
| v.uuid() | UuidSchema | extends StringSchema |
| v.url() | UrlSchema | extends StringSchema |
| v.enum([...]) | EnumSchema | literal union types |
| v.literal(x) | LiteralSchema | exact match |
| v.any() | AnySchema | passthrough |
| v.unknown() | UnknownSchema | passthrough |
| v.object({...}) | ObjectSchema | nested objects |
| v.array(...) | ArraySchema | accepts a schema or an object shorthand |
| v.column(t, c) | ColumnRef | for unique(v.column("users","email")) |
| v.extend(name, fn) | void | register a custom primitive |
| v.setAdapter(a) | void | register the async DB adapter |
Every fluent rule returns a new schema — schemas are immutable, so passing a single UserSchema around never causes accidental mutation.
Adding custom primitives
v.extend("phone", () => v.string().regex(/^\+?[0-9 ]{7,15}$/));
v.extend("currency", (code: string) =>
v.string().regex(new RegExp(`^\\d+(\\.\\d{1,2})? ${code}$`)),
);
// v.phone(), v.currency("USD")Parsing API
Four methods on every schema:
schema.parse(input) // O throws ValidationError
schema.safeParse(input) // SafeParseResult<O>
schema.parseAsync(input) // Promise<O> throws ValidationError
schema.safeParseAsync(input) // Promise<SafeParseResult<O>>SafeParseResult<T> is a discriminated union:
type SafeParseSuccess<T> = { success: true; data: T };
type SafeParseFailure = { success: false; error: ValidationError };
type SafeParseResult<T> = SafeParseSuccess<T> | SafeParseFailure;Use safeParse when you want a non-throwing API, parse when failure is a programmer error.
Always use
parseAsync/safeParseAsyncwhen your schema hasunique/existsrules.
Client-side parsing (.client())
unique and exists hit the database through an adapter, so they only make sense on the server. When you validate in the browser — e.g. a live form — strip them out with .client() and run the rest of the schema synchronously:
// Server (with a DB adapter): checks uniqueness in the DB
const r = await UserSchema.safeParseAsync(input);
// Client (browser): skips exists/unique, everything else still validates, runs sync
const r = UserSchema.client().safeParse(input);.client() returns a deep clone of the schema with every exists / unique rule removed — including fields nested inside objects and arrays. It shares no state with the original, so the server-side schema keeps its async rules:
const User = v.object({
email: v.email().required().unique("users.email"),
password: v.string().required().min(8),
});
User.client().safeParse({ email: "[email protected]", password: "secret123" }); // ✓ unique skipped, runs sync
await User.safeParseAsync({ email: "[email protected]", password: "secret123" }); // server: needs adapterNon-server rules (required, min/max, email shape, …) still run on the client, so users get instant feedback without a round-trip to the server.
Type Inference
The schema is the source of truth — no interface, no type, no z.infer-style helper required.
import type { Infer, Input, Output } from "@kmacute/vin-validation";
const User = v.object({
username: v.string().required(),
age: v.number(),
role: v.enum(["admin", "user"]),
tags: v.array(v.string()),
profile: v.object({
bio: v.string().nullable(),
}),
});
type User = Infer<typeof User>; // output type
type UserInput = Input<typeof User>; // input type (looser)
type UserOutput = Output<typeof User>; // alias of InferWhat you get automatically:
| Schema | Inferred type |
| ------------------------------ | ----------------------------------- |
| v.string() | string \| undefined |
| v.string().required() | string |
| v.string().nullable() | string \| null \| undefined |
| v.string().optional() | string \| undefined |
| v.number() | number \| undefined |
| v.boolean() | boolean \| undefined |
| v.date() | Date \| undefined |
| v.bigint() | bigint \| undefined |
| v.email() | string \| undefined |
| v.enum(["a", "b"]) | "a" \| "b" \| undefined |
| v.literal("admin") | "admin" \| undefined |
| v.array(v.string()) | string[] \| undefined |
| v.object({ a: v.string() }) | { a?: string \| undefined } |
| v.object({ a: v.string().required() }) | { a: string } |
Rules of thumb:
- Non-required fields (plain,
.optional()) may be missing → their output isT | undefinedand object keys are optional. .required()removesundefinedand makes object keys required..nullable()addsnull..optional()is purely a runtime marker (empty values are skipped) — the inferred type is the same as a plain field.
The inference is recursive and works through nested arrays/objects/optionals.
Required, Nullable, Optional
v.string() // accepts undefined, rejects null
v.string().required() // rejects undefined
v.string().nullable() // accepts null
v.string().optional() // behaves like required(false) — value may be missing
v.string().required().nullable() // rejects undefined, accepts nullTruth table:
| | input undefined | input null | input value |
| ---------- | ----------------- | ------------ | ----------- |
| (default) | ✓ → undefined | ✗ required | ✓ |
| .required() | ✗ required | ✗ required | ✓ |
| .nullable() | ✓ → undefined | ✓ → null | ✓ |
| .required().nullable() | ✗ required | ✓ → null | ✓ |
| .optional() | ✓ → undefined | ✗ required | ✓ |
| .optional().nullable() | ✓ → undefined | ✓ → null | ✓ |
.required()also rejects empty values — not justundefined/null. An empty string ("") or empty array ([]) is treated as missing and fails immediately with therequirederror, so it never also reports other rules likemin:v.string().required().min(3).safeParse(""); // ✗ "required" only (not also "min") v.array(v.string()).required().safeParse([]); // ✗ "required" only
.optional()is the mirror image: an optional field with no value skips validation entirely. An empty string ("") or empty array ([]) is treated as missing, so the type check and every rule (min,max,undefined:v.string().optional().min(5).safeParse(""); // ✓ → undefined v.string().optional().min(5).safeParse("hi"); // ✗ "min" (value present) v.number().optional().safeParse(""); // ✓ → undefined (no type error) v.array(v.string()).optional().min(2).safeParse([]); // ✓ → undefined v.string().optional().requiredIf("flag", true).safeParse(""); // ✗ "required" when the condition holds
Validation Rules Reference
All rules follow Laravel naming. Rules with :min / :max / :value placeholders are interpolated into the default message.
Presence
| Rule | Description |
| ------------- | -------------------------------------------- |
| required | value must be present and not empty (undefined, null, "", []) |
| nullable | value may be null |
| optional | value may be omitted |
| prohibited | value must NOT be present |
| accepted | value must be true, "yes", "on", or 1 |
| declined | value must be false, "no", "off", or 0 |
Type-specific
| Rule | Applies to | Description |
| ------------------- | ---------- | ---------------------------------------- |
| min(n) | all | polymorphic — char length / value / array length |
| max(n) | all | polymorphic |
| between(min, max) | all | polymorphic, inclusive |
| size(n) | all | polymorphic, exact |
| integer | number | whole number |
| positive | number | > 0 |
| negative | number | < 0 |
| finite | number | not Infinity / NaN |
| digits(n) | string | exactly n digits |
| digitsBetween(a,b)| string | a..b digits |
String
| Rule | Description |
| ------------------- | -------------------------------------------- |
| email | RFC-ish email pattern |
| uuid | UUID v1–5 |
| url | valid http:// or https:// URL |
| regex(re) | matches the regex |
| alpha | letters only |
| alphaNumeric | letters and digits |
| alphaDash | letters, digits, _, - |
| lowercase | all lowercase |
| uppercase | all uppercase |
| startsWith(s) | string prefix |
| endsWith(s) | string suffix |
| contains(s) | string contains |
| in([...]) | value is one of |
| notIn([...]) | value is none of |
Date
| Rule | Description |
| -------------------------- | ------------------------------------ |
| before(d) | before the given date |
| after(d) | after the given date |
| beforeOrEqual(d) | on or before the given date |
| afterOrEqual(d) | on or after the given date |
Array
| Rule | Description |
| ------------- | -------------------------------------------- |
| min(n) | at least n items |
| max(n) | at most n items |
| size(n) | exactly n items |
| nonEmpty | at least one item |
| distinct | no duplicates |
Cross-field
| Rule | Description |
| ------------- | -------------------------------------------- |
| confirmed | requires matching <field>_confirmation |
| same(other) | equals another field |
| different(other) | differs from another field |
Conditional presence / exclusion
| Rule | Description |
| ----------------------------- | ------------------------------------------------------ |
| requiredIf(field, value) | required when sibling equals value |
| requiredUnless(field, [...])| required unless sibling is in the list |
| requiredWith(field) | required when sibling is present |
| requiredWithout(field) | required when sibling is absent |
| excludeIf(field, value) | field excluded from output when sibling equals value |
| excludeUnless(field, [...]) | field excluded unless sibling is in the list |
| prohibitedIf / prohibitedUnless | like prohibited but conditional |
Async
| Rule | Description |
| ----------------------------------------------- | --------------------------------- |
| exists({ table, column }) / exists(fn) | async existence check |
| unique({ table, column }) / unique(fn) | async uniqueness check |
Custom
| Rule | Description |
| --------------------------------- | ---------------------------------------------- |
| refine(fn, message?) | Zod-style refinement |
| custom(name, fn, message?) | named custom validator |
| messages({ ... }) | override default messages per schema |
Custom messages
Every rule accepts an optional message:
v.string().required("Username is mandatory")You can also override globally per schema:
v.string().required().min(3).max(50).messages({
required: "Username is mandatory",
min: "Username must be at least 3 characters",
});Default messages
Each rule ships with a Laravel-style default:
The :attribute field is required.
The :attribute must be at least :min.
The :attribute may not be greater than :max.
The :attribute must be between :min and :max.
The :attribute must be a valid email address.
The :attribute must be a valid UUID.
The :attribute format is invalid.
The :attribute must be a date after :value.
The :attribute confirmation does not match.
The :attribute has already been taken.
The :attribute and :other must match.
The selected :attribute is invalid.Placeholders are substituted automatically:
| Placeholder | Value |
| -------------- | ------------------------------------ |
| :attribute | humanized field name ("userName" → "User Name") |
| :min | the rule's min parameter |
| :max | the rule's max parameter |
| :value | the rule's value parameter |
| :other | the field referenced by same/different/requiredIf |
| :values | comma-joined list of values |
Polymorphic min / max / between / size
Just like Laravel, the meaning depends on the underlying type:
v.string().min(3) // at least 3 characters
v.number().min(3) // value >= 3
v.array(...).min(3) // at least 3 items
v.bigint().min(3n) // value >= 3nSame for max, between, and size.
Array Validation
Arrays are first-class: the item schema is validated for every element and the array itself is size-checked. Async rules like unique work per-item and the whole validator returns a Promise whenever any item is async.
Built-in array rules
| Rule | Description |
| ------------- | -------------------------------------------------------- |
| min(n) | at least n items |
| max(n) | at most n items |
| size(n) | exactly n items |
| nonEmpty | at least one item |
| distinct | no duplicates (compared by JSON.stringify) |
const Tags = v.array(v.string().min(1)).min(1).max(10).distinct();
Tags.safeParse(["a", "b", "c"]); // ✓
Tags.safeParse(["a", "a", "a"]); // ✗ distinct
Tags.safeParse([]); // ✗ min
Tags.safeParse("not-an-array"); // ✗ must be an arrayArray-level errors (min, max, size, nonEmpty, distinct) report under the array's own field name (e.g. items), or under _ when the array is the top-level input. Item-level errors report under their dotted path (0.qty, 1.email, …).
Object shorthand
v.array({...}) is sugar for v.array(v.object({...})). Every field on each item is validated:
const LineItems = v.array({
sku: v.string().required(),
qty: v.number().required().min(1),
price: v.number().required().positive(),
}).min(1).max(50);Error paths use dotted indices, so a bad item reports as 0.qty, not 0[qty]:
const r = LineItems.safeParse([
{ sku: "A-1", qty: 0, price: 9.99 },
]);
// r.error.toJSON() => {
// message: "The given data was invalid.",
// errors: { "0.qty": ["The qty must be at least 1."] }
// }Per-item async rules
Async rules on the item schema run for every element. The whole validator becomes async:
const InviteUsers = v.object({
emails: v.array(
v.email().required().unique("users.email"),
).min(1).max(25),
});
const r = await InviteUsers.safeParseAsync({
emails: ["[email protected]", "[email protected]", "[email protected]"],
});
// "[email protected]" already exists -> "0.email" / "1.email" / "2.email"Transformations
transform works on both the item schema and the whole array:
// Normalize every item
v.array(v.string().email().trim().lowercase()).max(100);
// Sort + dedupe the whole array
v.array(v.number()).transform((arr) => Array.from(new Set(arr)).sort((a, b) => a - b));Array Cross-Field Validation
Cross-field rules work two ways inside an array:
- Across items — use
.refine(...)on the parent object (orcustom(...)on the parent) so the callback receives the whole record. - From an item to the parent object — use
same("parentField")/different("parentField")(which resolve against the item's parent), or any conditional rule (which resolves against the root).
Example: each line item's total must equal qty * price
const Order = v.object({
items: v.array(
v.object({
sku: v.string().required(),
qty: v.number().required().min(1),
price: v.number().required().positive(),
total: v.number().required().positive(),
}).refine(
// `item` here is the array item itself; the first arg of `.refine`
// is the value being validated (the item).
(item) => Math.abs(item.total - item.qty * item.price) < 0.01,
{ message: "The total must equal qty * price." },
),
).min(1),
});Example: a primaryEmail field, then all other emails must be different from it
const Contact = v.object({
primaryEmail: v.email().required(),
alternates: v.array(
v.object({
label: v.string().required().min(1),
email: v.email().required().different("primaryEmail"),
}),
).min(0).max(5),
});different("primaryEmail") walks up to the array item's parent (the Contact object) and reads primaryEmail. Any dotted path is also accepted, e.g. different("shipping.country").
Example: at most one primary address in a list
This is cross-item validation. The natural place is a .refine(...) on the parent object, which receives the whole record:
const Addresses = v.object({
billing: v.object({
country: v.string().required(),
}),
addresses: v.array(
v.object({
label: v.string().required(),
isPrimary: v.boolean().required(),
}),
).min(1),
}).refine(
(data) => data.addresses.filter((a) => a.isPrimary).length <= 1,
{ message: "Only one address can be marked as primary.", path: ["addresses"] },
);Because path: ["addresses"] is set on the refinement, the error is reported under addresses, not the root.
Example: confirmed for a per-item field
confirmed is a string-only rule that pairs a field with <field>_confirmation on the same item:
const NewPasswords = v.array(
v.object({
password: v.string().required().min(8).confirmed(),
}),
).min(1);
NewPasswords.safeParse([
{ password: "secret123", password_confirmation: "secret123" }, // ✓
{ password: "secret456", password_confirmation: "wrong" }, // ✗
// error path: "1.password"
]);Array Conditional Rules
requiredIf, requiredUnless, requiredWith, requiredWithout, excludeIf, excludeUnless, prohibitedIf, and prohibitedUnless all work on item schemas. The other field is resolved against the root input, so an item can react to:
- a sibling on the same item (e.g.
"type"), - a sibling on the parent object (e.g.
"shippingMethod"), - or another item in the array (e.g.
"0.sku").
Example: a note is required on every line item when type === "custom"
const Quote = v.object({
type: v.enum(["standard", "custom"]),
items: v.array(
v.object({
sku: v.string().required(),
qty: v.number().required().min(1),
note: v.string().requiredIf("type", "custom").min(5),
}),
).min(1),
});
Quote.safeParse({ type: "custom", items: [{ sku: "X", qty: 1, note: "rush" }] }); // ✓
Quote.safeParse({ type: "custom", items: [{ sku: "X", qty: 1 }] }); // ✗ note required (path: "items.0.note")
Quote.safeParse({ type: "standard", items: [{ sku: "X", qty: 1 }] }); // ✓ no note neededThe lookup path "type" resolves against the root (the quote), not the item — which is exactly what you want here.
Example: a trackingNumber is required when a sibling carrier is present
const Shipment = v.object({
carrier: v.string().optional(),
packages: v.array(
v.object({
trackingNumber: v.string().requiredWith("carrier").min(6),
}),
).min(1),
});
Shipment.safeParse({ packages: [{ trackingNumber: "1Z999AA1" }] }); // ✓ carrier absent
Shipment.safeParse({ carrier: "UPS", packages: [{ trackingNumber: "1Z999AA1" }] }); // ✓ both present
Shipment.safeParse({ carrier: "UPS", packages: [{ trackingNumber: "" }] }); // ✗ required when carrier presentExample: excludeIf to drop optional fields conditionally
const Payout = v.object({
method: v.enum(["bank", "paypal", "check"]),
recipients: v.array(
v.object({
method: v.enum(["bank", "paypal", "check"]),
bankAccount: v.string().excludeUnless("method", ["bank"]),
paypalEmail: v.email().excludeUnless("method", ["paypal"]),
}),
).min(1),
});
Payout.safeParse({
method: "bank",
recipients: [{ method: "paypal", paypalEmail: "[email protected]" }],
});
// -> recipients.0.bankAccount is dropped from the output (excluded, not invalid)Example: prohibitedIf to forbid sensitive data when not needed
const Survey = v.object({
isAnonymous: v.boolean().required(),
responses: v.array(
v.object({
questionId: v.uuid().required(),
email: v.email().prohibitedIf("isAnonymous", true),
}),
).min(1),
});
Survey.safeParse({
isAnonymous: true,
responses: [{ questionId: "...", email: "[email protected]" }],
});
// -> error: "responses.0.email: The email field is prohibited."Example: cross-item condition (requiredWith against another item)
Use a dotted path to point at another item. Below, every phone entry after the first requires a countryCode because the first item defines it.
const PhoneBook = v.object({
entries: v.array(
v.object({
label: v.string().required(),
countryCode: v.string().requiredWith("0.countryCode"),
phone: v.string().required(),
}),
).min(1),
});Because cross-item paths are static strings, this pattern is best for structural conditions (e.g. "the first item sets X, others must respect it"). For richer cross-item rules, use
.refine()on the parent object — see Array Cross-Field Validation.
Cross-Field Validation
confirmed
const ChangePassword = v.object({
password: v.string().required().min(8).confirmed(),
});
// input MUST include `password_confirmation` matching `password`
ChangePassword.safeParse({
password: "secret123",
password_confirmation: "secret123", // ✓
});
ChangePassword.safeParse({
password: "secret123",
password_confirmation: "wrong", // ✗
});same / different
v.object({
password: v.string().required(),
passwordConfirm: v.string().required().same("password"),
alternativeEmail: v.string().email().different("email"),
});Conditional Rules
requiredIf / requiredUnless / requiredWith / requiredWithout
v.object({
type: v.string(),
// Required when type === "user"
name: v.string().requiredIf("type", "user"),
// Required unless type is "guest" or "anonymous"
company: v.string().requiredUnless("type", ["guest", "anonymous"]),
// Required when email is present
phone: v.string().requiredWith("email"),
// Required when email is absent
backupEmail: v.string().requiredWithout("email"),
});excludeIf / excludeUnless
Excludes the field from the output (and skips validation) when the condition matches:
v.object({
type: v.string(),
// Excluded from output when type === "guest"
adminNotes: v.string().excludeIf("type", "guest"),
// Excluded unless type is in the list
legacyField: v.string().excludeUnless("type", ["admin", "staff"]),
});Transformations
v.string().trim()
v.string().lowercase() // transform (renamed in error message context)
v.string().uppercase() // transform
v.string().capitalize()Note: there are two
lowercase/uppercase— the rule (lowercase()as a check) and the transform (used by.lowercase()when chained after a transformer). They share names for ergonomics; both work intuitively when chained.
Custom transforms
v.string().transform((v) => `${v}!`)
v.number().transform((v) => v * 2)Default values
v.string().default("anonymous")
v.number().defaultFn(() => Date.now())Defaults are applied when the input is undefined, before validation runs. They are also re-transformed.
Custom Validation
.refine — Zod-style
v.string().refine(
(v, ctx) => v.startsWith("@"),
{ message: "Must start with @", path: ["items", 0, "name"] },
);.custom — named
v.string().custom("isPalindrome", (v) => {
if (typeof v !== "string") return "must be a string";
return v === v.split("").reverse().join("") ? undefined : "not a palindrome";
});.extend — global primitives
v.extend("postalCode", (country = "US") =>
v.string().regex(/^\d{5}(-\d{4})?$/).messages({
regex: `The :attribute must be a valid ${country} postal code.`,
}),
);Async Validation (Drizzle ORM)
This is the headline feature. unique and exists are async, pluggable, and integrate with the Drizzle adapter for the create / update flow.
Setup
import { v, type Infer } from "@kmacute/vin-validation";
import { drizzleAdapter } from "@kmacute/vin-validation/drizzle";
import { db } from "./db";
import * as schema from "./schema";
v.setAdapter(drizzleAdapter(db, schema));The adapter:
- looks up
schema["users"]andschema.users["email"]forunique("users.email") - runs
select({ _v: count() }).from(table).where(eq(col, value)).limit(1) - works on any Drizzle dialect (Postgres, MySQL, SQLite, Turso, D1, Neon, ...)
- handles
count()returningnumber | string | bigint(Postgres returnsstring)
Create flow — just .unique(...)
const CreateUser = v.object({
email: v.email().required().unique("users.email"),
username: v.string().required().min(3).unique("users.username"),
});
const r = await CreateUser.safeParseAsync({
email: "[email protected]",
username: "kevs",
});
// SQL: SELECT count(*) FROM users WHERE email = '[email protected]'
// SELECT count(*) FROM users WHERE username = 'kevs'Update flow — .primaryKey() auto-wires the ignore
const UpdateUser = v.object({
id: v.uuid().primaryKey(),
email: v.email().required().unique("users.email"),
});
const r = await UpdateUser.safeParseAsync({
id: "550e8400-e29b-41d4-a716-446655440000",
email: "[email protected]",
});
// SQL: SELECT count(*) FROM users
// WHERE email = '[email protected]'
// AND id <> '550e8400-e29b-41d4-a716-446655440000'The check passes when no other row has the email — exactly Laravel's
Rule::unique('users','email')->ignore($userId).
Custom primary key column
const UpdateUser = v.object({
userId: v.uuid().primaryKey("user_id"), // DB column is `user_id`, not `id`
email: v.email().required().unique("users.email"),
});Skip the DB call when the value didn't change
If you already have the existing record, pass .currentValue(...). The check is short-circuited (no DB query) when the input equals it:
const existing = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.id, id),
});
const email = v.email().required().unique("users.email").currentValue(existing.email);
const UpdateUser = v.object({
id: v.uuid().primaryKey(),
email,
});
await UpdateUser.safeParseAsync({
id: existing.id,
email: "[email protected]", // equals currentValue -> check skipped
});exists rule
const AssignRole = v.object({
roleId: v.uuid().required().exists("roles.id"),
});exists returns true when the row exists, so the check passes when the value matches a real record.
Column references
Three equivalent forms:
v.email().unique("users.email") // string shorthand
v.email().unique(v.column("users", "email")) // fully-typed factory
v.email().unique({ table: "users", column: "email" }) // object formv.column(...) is a factory for ColumnRef — a frozen { __columnRef, table, column } object. Useful when you want compile-time safety and a named import.
Custom schema lookups
drizzleAdapter(db, schema, {
resolveTable: (name) => schema[`app_${name}`],
resolveColumn: (table, column) => table[column],
});Custom Async Adapter
If you don't use Drizzle, register any object that implements exists and unique:
import { and, eq, ne } from "drizzle-orm";
v.setAdapter({
async exists({ table, column, value }) {
const row = await db
.select()
.from(table)
.where(eq((table as any)[column], value))
.limit(1);
return row.length > 0;
},
async unique({ table, column, value, ignore }) {
const conditions = [eq((table as any)[column], value)];
if (ignore?.id !== undefined) {
conditions.push(ne((table as any)[ignore.column ?? "id"], ignore.id));
}
const row = await db
.select()
.from(table)
.where(and(...conditions))
.limit(1);
return row.length === 0;
},
});Or a function shortcut per-rule:
v.string().exists(async (value) => {
return (await db`SELECT 1 FROM users WHERE id = ${value}`).length > 0;
});Error Format
Identical to Laravel:
{
message: "The given data was invalid.",
errors: {
username: [
"The username field is required."
],
"items.0.name": [
"The item name field is required."
]
}
}errorsis keyed by dotted path —items.0.namenotitems[0].name- Each field has an array of messages (multiple errors per field)
messageis the envelope message; override globally viasetAdapterdoesn't change it
ValidationError API
class ValidationError extends Error {
message: string;
errors: Record<string, string[]>;
has(field: string): boolean;
first(field?: string): string | undefined;
get(field: string): string[];
all(): string[];
keys(): string[];
firstErrors(): Record<string, string>;
toJSON(): { message: string; errors: Record<string, string[]> };
}Example:
const r = schema.safeParse(input);
if (!r.success) {
r.error.has("email") // true / false
r.error.first("email") // first email error
r.error.first() // first error of any field
r.error.get("email") // string[] of all email errors
r.error.all() // flat string[] of every error
r.error.keys() // fields with at least one error
r.error.firstErrors() // { email: "The email..." }
r.error.toJSON() // Laravel envelope
}Real-World Examples
Signup form
import { v, type Infer } from "@kmacute/vin-validation";
import { drizzleAdapter } from "@kmacute/vin-validation/drizzle";
import { db } from "~/db";
import * as schema from "~/schema";
v.setAdapter(drizzleAdapter(db, schema));
export const SignupSchema = v.object({
email: v.email().required().unique("users.email"),
username: v.string().required().min(3).max(20).unique("users.username"),
password: v.string().required().min(8).confirmed(),
age: v.number().min(13),
acceptTos: v.boolean().required().accepted(),
roles: v.array(v.enum(["admin", "staff"])).min(1),
profile: v.object({
firstName: v.string().required().min(1).max(50),
lastName: v.string().required().min(1).max(50),
bio: v.string().max(500).nullable(),
}),
notifications: v.object({
email: v.boolean(),
sms: v.boolean(),
}),
});
type Signup = v.Infer<typeof SignupSchema>;Edit profile (with primary key + current value short-circuit)
const existing = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.id, input.id),
});
const UpdateProfile = v.object({
id: v.uuid().primaryKey(),
email: v.email()
.required()
.unique("users.email")
.currentValue(existing.email), // skip DB if email unchanged
displayName: v.string().required().min(2).max(50),
bio: v.string().max(500).nullable(),
});
const r = await UpdateProfile.safeParseAsync(input);
if (r.success) {
await db.update(schema.users).set(r.data).where(eq(schema.users.id, r.data.id));
} else {
return c.json(r.error.toJSON(), 422);
}In a TanStack Start server function
// src/server/users.ts
import { createServerFn } from "@tanstack/react-start";
import { UpdateProfile } from "~/schemas/user";
export const updateProfile = createServerFn({ method: "POST" })
.validator((input: unknown) => UpdateProfile.parseAsync(input))
.handler(async ({ data }) => {
await db.update(schema.users).set(data).where(eq(schema.users.id, data.id));
return { ok: true };
});Hono / Express / Fastify middleware
import { Hono } from "hono";
const app = new Hono();
app.post("/users", async (c) => {
const body = await c.req.json();
const r = await SignupSchema.safeParseAsync(body);
if (!r.success) {
return c.json(r.error.toJSON(), 422);
}
// r.data is fully typed
return c.json({ id: "..." });
});Performance
vin is on par with zod on the parse hot path and 22-35x faster than yup. Schema construction is 1.7x faster than zod and 5x faster than yup. All measurements below were taken on Node 23 with a representative signup-form schema (nested object + array of items + regex + transforms + min/max):
| Workload | vin ops/sec | zod ops/sec | yup ops/sec | | --------------------------- | ----------- | ----------- | ----------- | | Schema construction (avg) | 0.06 ms | 0.10 ms | 0.35 ms | | Warm valid parse | 261,471 | 319,170 | 7,388 | | Warm invalid parse | 120,784 | 118,568 | 7,690 | | Varied parse (mixed inputs) | 164,410 | 163,457 | 7,582 | zod: 1.7x faster at construction, statistically tied on the parse hot path. On invalid inputs vin is marginally faster; on valid inputs zod is ~22% faster thanks to its tighter hot loop. The two libraries are within noise of each other for typical workloads. yup: 22-35x faster on parse, 5-6x faster on construction. yup's runtime type checking and async path add significant overhead.
How it works
- The validator pipeline is built once per schema (schema.compile()) and cached. The first call to .safeParse() triggers compilation; subsequent calls hit the same closure without re-walking rules.
- The compiled validator is a single function -- no per-call instanceof chains beyond a single isPromise check.
- ValidationError does not extend Error directly. Extending Error causes V8 to capture a stack trace on every super() call; we set the prototype chain manually so instanceof Error still works, and .stack is captured lazily on first read. Without this, safeParse on an invalid input was ~15x slower.
Run it yourself
ash clone https://github.com/kmacute/vin.git vin/packages/vin install run bench
$NL bench script lives at ench/bench.mjs and covers schema construction, warm valid parse, warm invalid parse, and varied parse across all three libraries.
Architecture
Internally the library uses Feature-Sliced Design:
src/
├── async/ # exists / unique + adapters
│ └── adapters.ts # ExistsAdapter interface, parseColumnRef
├── errors/ # ValidationError, ErrorCollector
├── messages/ # Default messages + Laravel-style formatting
├── public/ # v namespace + type exports
├── schema/ # Base AnySchema class + compiled pipeline
├── types/ # Infer / Input / Output helpers
├── validators/ # string, number, date, format, complex
├── adapters/
│ └── drizzle.ts # First-class Drizzle adapter
└── index.tsCompilation & Runtime
- The compiled validator is built once per schema and cached. Calling
safeParserepeatedly is cheap. - Rules are appended to a
Rule[]on each fluent call, then traversed in order. - No reflection, no runtime AST walking.
- Async rules are detected and the validator returns a
Promise<O>so async work is pipelined correctly. - Cross-field rules use the parent
datareference — no field re-validation.
Tree-shaking
The package is ESM-first and sideEffects: false. Unused primitives are dropped from the final bundle.
Strict TypeScript
strict: truenoUncheckedIndexedAccess: truenoImplicitAny: true- All public types are exported and fully generic.
License
MIT
