A type-safe validation library for TypeScript that provides a fluent API for creating validators with business rules and dependency injection. Built on top of the Standard Schema specification.
- Type-safe validation with full TypeScript support
- Standard Schema support - works with Zod (and other compatible libraries)
- Fluent API - chainable methods for building validators
- Business rules with context passing between rules
- Command pattern - validation + execution in one step
- Dependency injection with compile-time type checking
- Multiple error formats - object, flatten, HTML, text
- Efficient object reuse - same instance, different types
npm install model-validator-ts
# or
yarn add model-validator-ts
# or
pnpm add model-validator-ts
import { createValidator } from 'model-validator-ts';
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(3),
age: z.number().min(18),
email: z.string().email()
});
// Simple validation without dependencies
const validator = createValidator().input(userSchema);
const result = await validator.validate({
name: "John",
age: 25,
email: "john@example.com"
});
if (result.success) {
console.log("Valid user:", result.value);
console.log("Context:", result.context);
} else {
console.log("Validation errors:", result.errors.toObject);
}
interface UserRepository {
findByEmail(email: string): Promise<{ id: string } | null>;
}
const userValidator = createValidator()
.input(userSchema)
.$deps<{ userRepo: UserRepository }>()
.addRule({
fn: async ({ data, deps, bag }) => {
// Check if email is already taken
const existingUser = await deps.userRepo.findByEmail(data.email);
if (existingUser) {
bag.addError("email", "Email is already taken");
}
}
})
.provide({ userRepo: myUserRepository });
const result = await userValidator.validate(userData);
const layerValidator = createValidator()
.input(z.object({
layerId: z.string(),
visibility: z.enum(["public", "private"])
}))
.$deps<{ layerRepo: LayerRepository }>()
.addRule({
fn: async ({ data, deps, bag }) => {
const layer = await deps.layerRepo.getLayer(data.layerId);
if (!layer) {
bag.addError("layerId", "Layer not found");
return;
}
// Return context for next rules
return { context: { layer } };
}
})
.addRule({
fn: async ({ data, context, bag }) => {
// Access context from previous rule
if (context.layer.classification === "confidential" &&
data.visibility === "public") {
bag.addError("visibility", "Confidential layers cannot be public");
}
return { context: { validated: true } };
}
})
.provide({ layerRepo });
const transferMoneyCommand = createValidator()
.input(z.object({
fromAccount: z.string(),
toAccount: z.string(),
amount: z.number().positive()
}))
.$deps<{ db: DatabaseService }>()
.addRule({
fn: async ({ data, bag }) => {
// Business rule validation
if (data.fromAccount === data.toAccount) {
bag.addError("toAccount", "Cannot transfer to same account");
}
}
})
.command({
execute: async ({ data, deps, context, bag }) => {
try {
// Execute the business logic
await deps.db.executeTransaction(async () => {
await deps.db.debit(data.fromAccount, data.amount);
await deps.db.credit(data.toAccount, data.amount);
});
return {
transactionId: `txn-${Date.now()}`,
status: "completed",
...data
};
} catch (error) {
// Handle runtime errors
bag.addError("global", `Transaction failed: ${error.message}`);
return bag; // Return error bag
}
}
});
// Execute command
const result = await transferMoneyCommand
.provide({ db: databaseService })
.run({
fromAccount: "acc-123",
toAccount: "acc-456",
amount: 100
});
if (result.success) {
console.log("Transfer successful:", result.result);
console.log("Context:", result.context);
} else {
console.log("Transfer failed at step:", result.step); // "validation" | "execution"
console.log("Errors:", result.errors.toText());
}
Define the input schema using any Standard Schema compatible library.
Declare the required dependencies type. Must be called before .provide()
.
Add a business rule function. Rules can:
- Add errors to the error bag
- Return context:
{ context: { key: value } }
- Access previous context and dependencies
Provide the actual dependency instances. Required before validation if $deps()
was called.
Run validation and return result with success
, value
/errors
, and context
.
Create a command that combines validation with execution logic.
Provide dependencies for command execution.
Execute the command with validation + business logic.
Type-safe version when input type is known.
Add an error for a specific field or "global".
Check if any errors exist.
Get the first error message for a field.
Get errors as { field: ["error1", "error2"] }
.
Get errors as { field: "error1" }
(first error only).
Format errors as text or HTML.
Validation results include a step
field to distinguish between:
"validation"
- Schema or business rule validation failed"execution"
- Runtime error during command execution
const result = await command.run(input);
if (!result.success) {
if (result.step === "validation") {
// Handle validation errors
console.log("Input validation failed:", result.errors.toObject);
} else {
// Handle execution errors
console.log("Execution failed:", result.errors.toObject);
}
}
- Schema types are automatically inferred from your schema
- Dependencies must be provided before validation/execution
- Context types accumulate through the rule chain
- Command results are properly typed based on execution function
// TypeScript will enforce these relationships:
const validator = createValidator()
.input(schema) // Infers input/output types
.$deps<{ service: T }>() // Requires provide() before validate()
.addRule({ ... }) // Rule receives typed data, deps, context
.provide(dependencies); // Type-checked against $deps<T>
// result.value is typed according to schema output
const result = await validator.validate(data);
MIT