npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

@kmacute/vin-validationvin

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 / exists checks
  • Tree-shakable, fully typed, exhaustively tested

Table of Contents

  1. Installation
  2. Quick Start
  3. The v Namespace
  4. Parsing API
  5. Type Inference
  6. Required, Nullable, Optional
  7. Validation Rules Reference
  8. Polymorphic min / max / between / size
  9. Array Validation
  10. Array Cross-Field Validation
  11. Array Conditional Rules
  12. Cross-Field Validation
  13. Conditional Rules
  14. Transformations
  15. Custom Validation
  16. Async Validation (Drizzle ORM)
  17. Custom Async Adapter (Prisma, Kysely, raw SQL)
  18. Error Format
  19. Real-World Examples
  20. Performance
  21. Architecture

Installation

# Core
npm install @kmacute/vin-validation
# or
pnpm add @kmacute/vin-validation

If you use Drizzle ORM and want the built-in adapter:

npm install @kmacute/vin-validation drizzle-orm
# drizzle-orm is an optional peer dependency

Supported 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 / safeParseAsync when your schema has unique / exists rules.

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 adapter

Non-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 Infer

What 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 is T | undefined and object keys are optional.
  • .required() removes undefined and makes object keys required.
  • .nullable() adds null.
  • .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 null

Truth 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 just undefined / null. An empty string ("") or empty array ([]) is treated as missing and fails immediately with the required error, so it never also reports other rules like min:

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, email, …) are skipped — they only apply once the user actually provides a value. The field is omitted from the output, as if it were 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 >= 3n

Same 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 array

Array-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:

  1. Across items — use .refine(...) on the parent object (or custom(...) on the parent) so the callback receives the whole record.
  2. 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 needed

The 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 present

Example: 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"] and schema.users["email"] for unique("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() returning number | string | bigint (Postgres returns string)

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 form

v.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."
    ]
  }
}
  • errors is keyed by dotted pathitems.0.name not items[0].name
  • Each field has an array of messages (multiple errors per field)
  • message is the envelope message; override globally via setAdapter doesn'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.ts

Compilation & Runtime

  • The compiled validator is built once per schema and cached. Calling safeParse repeatedly 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 data reference — 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: true
  • noUncheckedIndexedAccess: true
  • noImplicitAny: true
  • All public types are exported and fully generic.

License

MIT