High-performance data validation library for TypeScript with schema inference, composable validators, and AI output validation.
Installation | Quick Start | API Reference | AI Output Validation | JSON Schema
Every modern TypeScript application needs runtime data validation. API responses, form inputs, configuration files, and now AI/LLM outputs all require structured validation with clear error reporting. Existing solutions either sacrifice performance for features, or features for simplicity.
valx provides a TypeScript-first schema definition API with:
- Full type inference from schemas (define once, get types for free)
- Composable, chainable validators with immutable schema instances
- Detailed error reporting with dot-notation paths
- First-class AI output validation (extract JSON from markdown, auto-fix common LLM issues)
- JSON Schema conversion for interoperability
- Zero external dependencies
Built in the age of AI agents, where tools like opencli (8k+ stars, newcomer_score: 9.7) and Trellis (4k+ stars) need reliable structured data validation for tool calls, function parameters, and agent responses.
npm install valximport { v } from 'valx';
// Define a schema
const userSchema = v.object({
name: v.string().min(1).max(100),
email: v.string().email(),
age: v.number().int().min(0).max(150),
role: v.enum(['admin', 'user', 'guest'] as const),
preferences: v.object({
theme: v.enum(['light', 'dark'] as const).default('light'),
notifications: v.boolean().default(true),
}).optional(),
});
// Type is automatically inferred
type User = ReturnType<typeof userSchema.parse>;
// Parse with error throwing
const user = userSchema.parse(data);
// Parse without throwing
const result = userSchema.safeParse(data);
if (result.success) {
console.log(result.data); // typed as User
} else {
console.log(result.issues); // structured error details
}
// Type guard
if (userSchema.is(unknownData)) {
// unknownData is now typed as User
}v.string() // any string
v.string().min(1) // at least 1 character
v.string().max(100) // at most 100 characters
v.string().length(10) // exactly 10 characters
v.string().nonempty() // shorthand for .min(1)
v.string().email() // email format
v.string().url() // URL format
v.string().uuid() // UUID format
v.string().isoDate() // YYYY-MM-DD
v.string().isoDateTime() // ISO 8601 datetime
v.string().json() // valid JSON string
v.string().regex(/^[a-z]+$/) // regex pattern
v.string().startsWith('http') // prefix check
v.string().endsWith('.json') // suffix check
v.string().includes('@') // contains substring
v.string().trim() // trim whitespace
v.string().toLowerCase() // transform to lowercase
v.string().toUpperCase() // transform to uppercase
v.string().coerce() // coerce numbers/booleans/dates to stringv.number() // any finite number
v.number().int() // integer only
v.number().min(0) // minimum value
v.number().max(100) // maximum value
v.number().positive() // > 0
v.number().negative() // < 0
v.number().nonnegative() // >= 0
v.number().multipleOf(5) // divisible by 5
v.number().allowInfinite() // allow Infinity/-Infinity
v.number().clamp(0, 100) // clamp to range (transform)
v.number().round() // round to integer (transform)
v.number().floor() // floor (transform)
v.number().ceil() // ceil (transform)
v.number().coerce() // coerce strings/booleans to numberv.boolean() // true or false
v.boolean().true() // must be true
v.boolean().false() // must be false
v.boolean().coerce() // coerce "true"/"false"/0/1v.literal('active') // exact string match
v.literal(42) // exact number match
v.literal(true) // exact boolean matchv.date() // Date instance
v.date().min(new Date('2024-01-01')) // after date
v.date().max(new Date('2024-12-31')) // before date
v.date().coerce() // coerce strings/timestampsconst schema = v.object({
name: v.string(),
age: v.number(),
});
schema.strict() // reject unknown keys
schema.strip() // remove unknown keys
schema.passthrough() // keep unknown keys (default)
schema.catchall(v.string()) // validate unknown keys
// Schema manipulation
schema.pick('name') // pick specific keys
schema.omit('age') // omit specific keys
schema.extend({ role: v.string() }) // add new keys
schema.partial() // make all keys optional
schema.merge(otherSchema) // merge two schemasv.array(v.string()) // array of strings
v.array(v.number()).min(1) // at least 1 element
v.array(v.number()).max(10) // at most 10 elements
v.array(v.number()).length(5) // exactly 5 elements
v.array(v.number()).nonempty() // shorthand for .min(1)
v.array(v.number()).unique() // no duplicate values
v.array(v.object({ id: v.number() })).unique('id') // unique by key
v.array(v.number()).sort((a, b) => a - b) // sort (transform)v.tuple(v.string(), v.number()) // [string, number]
v.tuple(v.string()).rest(v.number()) // [string, ...number[]]v.record(v.number()) // Record<string, number>
v.record(v.number()).min(1) // at least 1 entry
v.record(v.number()).max(10) // at most 10 entries
v.recordWithKeys(
v.string().regex(/^[a-z]+$/), // validate keys too
v.number()
)v.enum(['a', 'b', 'c'] as const) // string enum
v.enum([1, 2, 3] as const) // numeric enum
v.nativeEnum(TypeScriptEnum) // TypeScript enum
schema.exclude('a') // remove a value
schema.extract('a', 'b') // keep specific values// Union (OR)
v.union(v.string(), v.number())
// Discriminated union (fast matching)
v.discriminatedUnion('type', [
v.object({ type: v.literal('dog'), breed: v.string() }),
v.object({ type: v.literal('cat'), indoor: v.boolean() }),
])
// Intersection (AND)
v.intersection(
v.object({ name: v.string() }),
v.object({ age: v.number() })
)v.any() // accepts anything
v.unknown() // accepts anything (prefer over any)
v.never() // rejects everything
v.void() // accepts undefined
v.null() // accepts null
v.undefined() // accepts undefined
v.instanceof(Date) // instanceof check
v.lazy(() => schema) // recursive schemas// Available on all schemas:
schema.optional() // T | undefined
schema.nullable() // T | null
schema.default(value) // provide default for undefined
schema.describe('description') // add metadata
schema.refine(fn, message) // custom validation
schema.transform(fn) // transform outputconst password = v.string()
.min(8)
.refine(s => /[A-Z]/.test(s), 'Must contain uppercase')
.refine(s => /[0-9]/.test(s), 'Must contain a digit');
// Refinements can return string for dynamic messages
const age = v.number().refine(n => {
if (n < 0) return 'Cannot be negative';
if (n > 150) return 'Unrealistic age';
return true;
});// Transform output type
const schema = v.string()
.transform(s => parseInt(s, 10))
.transform(n => n * 2);
// Input: string, Output: number
// Combine with defaults
const greeting = v.string()
.default('world')
.transform(s => `Hello, ${s}!`);
// greeting.parse(undefined) => "Hello, world!"valx includes first-class support for validating AI/LLM outputs, handling common issues like markdown code blocks, trailing commas, and comments.
import { v, validateAiOutput } from 'valx';
const responseSchema = v.object({
thought: v.string(),
action: v.object({
tool: v.string(),
input: v.record(v.unknown()),
}),
confidence: v.number().min(0).max(1),
});
// AI often wraps JSON in markdown code blocks
const aiOutput = `I'll use the search tool.
\`\`\`json
{
"thought": "Need to search for information",
"action": {
"tool": "search",
"input": {"query": "TypeScript validation"}
},
"confidence": 0.95
}
\`\`\``;
const result = validateAiOutput(aiOutput, responseSchema);
if (result.success) {
console.log(result.data); // typed and validated
console.log(result.wasExtracted); // true (extracted from markdown)
console.log(result.wasFixed); // false (no fixes needed)
} else {
console.log(result.retryPrompt); // suggested prompt for retry
}- JSON Extraction: Automatically extracts JSON from markdown code blocks
- Auto-fix: Handles trailing commas, single-line comments,
undefined/NaN/Infinityvalues - Retry Prompts: Generates structured retry prompts when validation fails
- Configurable: Toggle extraction, auto-fix, and suggestion limits
validateAiOutput(output, schema, {
extractJson: true, // extract from markdown (default: true)
autoFix: true, // fix common issues (default: true)
maxSuggestions: 3, // max error details in retry prompt (default: 3)
});Convert valx schemas to JSON Schema for use with OpenAPI, JSON Schema validators, or AI function calling definitions.
import { v, toJsonSchema } from 'valx';
const schema = v.object({
id: v.number().int().describe('User ID'),
name: v.string().min(1).max(255).describe('User name'),
status: v.enum(['active', 'inactive'] as const),
tags: v.array(v.string()),
metadata: v.record(v.unknown()).optional(),
});
const jsonSchema = toJsonSchema(schema);
// {
// type: "object",
// properties: {
// id: { type: "integer", description: "User ID" },
// name: { type: "string", minLength: 1, maxLength: 255, description: "User name" },
// status: { enum: ["active", "inactive"] },
// tags: { type: "array", items: { type: "string" } },
// metadata: {}
// },
// required: ["id", "name", "status", "tags"]
// }valx provides structured, path-aware error reporting.
const schema = v.object({
users: v.array(
v.object({
name: v.string(),
age: v.number().int().min(0),
})
),
});
const result = schema.safeParse({
users: [
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: -5 },
],
});
if (!result.success) {
for (const issue of result.issues) {
console.log(issue.path); // ['users', '1', 'age']
console.log(issue.message); // 'Number must be >= 0'
console.log(issue.code); // 'too_small'
console.log(issue.expected); // '>= 0'
console.log(issue.received); // -5
console.log(issue.severity); // 'error'
}
}When using .parse(), a ValidationError is thrown with structured issues:
import { ValidationError } from 'valx';
try {
schema.parse(invalidData);
} catch (e) {
if (e instanceof ValidationError) {
console.log(e.issues); // ValidationIssue[]
console.log(e.message); // formatted error string
}
}const apiResponse = v.object({
status: v.enum(['success', 'error'] as const),
data: v.object({
users: v.array(v.object({
id: v.number().int().positive(),
name: v.string().min(1),
email: v.string().email(),
role: v.enum(['admin', 'user'] as const),
})),
pagination: v.object({
total: v.number().int().nonnegative(),
page: v.number().int().positive(),
perPage: v.number().int().positive(),
}),
}),
meta: v.object({
requestId: v.string().uuid(),
timestamp: v.string().isoDateTime(),
}),
});const configSchema = v.object({
server: v.object({
host: v.string().default('localhost'),
port: v.number().int().min(1).max(65535).default(3000),
}),
database: v.object({
url: v.string().nonempty(),
pool: v.object({
min: v.number().int().nonnegative().default(2),
max: v.number().int().positive().default(10),
}).optional(),
}),
logging: v.object({
level: v.enum(['debug', 'info', 'warn', 'error'] as const).default('info'),
}).optional(),
});const functionCall = v.discriminatedUnion('type', [
v.object({
type: v.literal('function_call'),
name: v.string().nonempty(),
arguments: v.string().json(),
}),
v.object({
type: v.literal('text'),
content: v.string(),
}),
v.object({
type: v.literal('tool_use'),
tool: v.string(),
input: v.record(v.unknown()),
}),
]);interface TreeNode {
value: string;
children?: TreeNode[];
}
const treeSchema: ReturnType<typeof v.object> = v.object({
value: v.string(),
children: v.array(v.lazy(() => treeSchema)).optional(),
});valx is designed for performance. Benchmarks on 10,000 objects with 3 fields each complete in under 100ms. Deeply nested schemas (10 levels) validate in under 10ms.
valx/
src/
core/types.ts # Base Schema class, ValidationError, core types
schemas/
string.ts # StringSchema with format validators
number.ts # NumberSchema with numeric constraints
boolean.ts # BooleanSchema with coercion
literal.ts # LiteralSchema for exact values
array.ts # ArraySchema with element validation
object.ts # ObjectSchema with pick/omit/extend/partial
tuple.ts # TupleSchema with rest elements
record.ts # RecordSchema for key-value maps
enum.ts # EnumSchema and NativeEnumSchema
date.ts # DateSchema with range validation
union.ts # UnionSchema, DiscriminatedUnionSchema, IntersectionSchema
special.ts # AnySchema, NeverSchema, LazySchema, InstanceOfSchema
utils/
ai-output.ts # AI/LLM output validation with auto-fix
json-schema.ts # JSON Schema conversion
index.ts # Main entry point with `v` builder
tests/ # 242 tests across 17 test files
- Fork the repository
- Create a feature branch
- Run tests:
npm test - Run type check:
npm run lint - Submit a pull request
MIT