Business Logic Validation in Typescript
TL;DR: Model Validator TS is an opinionated library to simplify business logic validations.
The Problem
Something I’m always worrying about when writing software is that I’m properly validating the business rules, from the shape of the data which is wonderfully handled by the TypeScript ecosystem (zod, standard-schema, etc.), to the more complex business rules like uniqueness, property relationships, current state of some other system, feature flags, etc. This second set of rules is something that we each implement our way, and I’ve seen in projects I’ve worked in that this is a very common source of complexity, messiness, and bugs.

Business logic validation
I think this happens because it’s not simple. Business logic can be very elaborate. Here’s an imaginary example of an ecommerce order cancellation process:
Only allow cancelling an order from the customer portal if it’s not already shipped or planned to be shipped in the next 24 hours, and all the items of the order are “cancellable” (downloadable product, personalized product, etc), and if the order did not have a special discount code, and if the order is not being fulfilled by a third party, and if the order was not created more than 10 days ago, only the customer can cancel their own orders, admin users can cancel any order, etc.
So now imagine implementing this. I’m sure there are lots of rules missing there, and the difficulty can increase if we imagine that there are multiple services involved and in this specific case we are dealing with money. We all don’t work in this domain, but every domain has its complexity.
That’s why I’ve always enjoyed reading about how other people approach these challenges, and have studied various approaches (Domain Model, Transaction Script, CQRS, Ruby on Rails Model Validations, Laravel Validation Rules, Yii2 Model Rules, etc).
Approach
That’s why I’ve decided to write a simple library (< 500 loc) that provides an opinionated way to validate business rules. The idea is to use TypeScript’s type system to make our lives easier when we need to combine a bunch of business logic and concerns together.
The library helps us have a schema (zod, standard-schema, etc) that will parse/validate the shape of the data, and give us full type safety. Then we can evaluate a set of rules.
These rules receive the already parsed data and have two key abilities:
- Safely provide validation errors that stop the flow of execution when business rules are violated
- Pass context between rules so subsequent rules can access data from previous ones
For example, if a rule queries a service/db to find a user by its user_id
, the next rules can access that User object and provide values of their own. Ultimately there’s a small optional abstraction of defining a handler as a way to put everything together into a “Command”: Validate Shape -> Validate Rules -> Do something
.
Example: User Login
Let’s look at a simple login example to demonstrate the concepts. For a more complex example implementing the order cancellation scenario mentioned above, check out this implementation.
import { import z
z } from "zod";import { function buildValidator(): FluentValidatorBuilder<any, {}, {}, "not-required">
buildValidator, type type InferCommandResult<TCommand extends Command<any, any, any, any, any>, TCondition extends "success" | "failure" | "all" = "all"> = TCommand extends Command<infer TSchema extends StandardSchemaV1<unknown, unknown>, any, infer TContext, infer TOutput, any> ? TCondition extends "success" ? { ...;} & { ...;} : TCondition extends "failure" ? { ...;} & { ...;} : CommandResult<...> : never
InferCommandResult } from "model-validator-ts";
interface interface User
User { User.id: string
id: string; User.role: "admin" | "customer"
role: "admin" | "customer"; User.email: string
email: string; User.passwordHash: string
passwordHash: string;}
declare const const userService: { findByEmail(email: string): Promise<User | null>; validatePassword(user: User, password: string): Promise<boolean>; generateToken(user: User): Promise<string>;}
userService: { function findByEmail(email: string): Promise<User | null>
findByEmail(email: string
email: string): interface Promise<T>
Represents the completion of an asynchronous operation
Promise<interface User
User | null>; function validatePassword(user: User, password: string): Promise<boolean>
validatePassword(user: User
user: interface User
User, password: string
password: string): interface Promise<T>
Represents the completion of an asynchronous operation
Promise<boolean>; function generateToken(user: User): Promise<string>
generateToken(user: User
user: interface User
User): interface Promise<T>
Represents the completion of an asynchronous operation
Promise<string>;};
const const loginSchema: z.ZodObject<{ email: z.ZodString; password: z.ZodString;}, "strip", z.ZodTypeAny, { email: string; password: string;}, { email: string; password: string;}>
loginSchema = import z
z.object<{ email: z.ZodString; password: z.ZodString;}>(shape: { email: z.ZodString; password: z.ZodString;}, params?: z.RawCreateParams): z.ZodObject<{ email: z.ZodString; password: z.ZodString;}, "strip", z.ZodTypeAny, { ...;}, { ...;}>export object
object({ email: z.ZodString
email: import z
z.function string(params?: z.RawCreateParams & { coerce?: true;}): z.ZodStringexport string
string().ZodString.email(message?: errorUtil.ErrMessage): z.ZodString
email(), password: z.ZodString
password: import z
z.function string(params?: z.RawCreateParams & { coerce?: true;}): z.ZodStringexport string
string().ZodString.min(minLength: number, message?: errorUtil.ErrMessage): z.ZodString
min(8),});
const const loginCommand: Command<z.ZodObject<{ email: z.ZodString; password: z.ZodString;}, "strip", z.ZodTypeAny, { email: string; password: string;}, { email: string; password: string;}>, {}, { user: User;}, { ...;}, "not-required">
loginCommand = function buildValidator(): FluentValidatorBuilder<any, {}, {}, "not-required">
buildValidator() .FluentValidatorBuilder<any, {}, {}, "not-required">.input<z.ZodObject<{ email: z.ZodString; password: z.ZodString;}, "strip", z.ZodTypeAny, { email: string; password: string;}, { email: string; password: string;}>>(schema: z.ZodObject<{ email: z.ZodString; password: z.ZodString;}, "strip", z.ZodTypeAny, { email: string; password: string;}, { email: string; password: string;}>): FluentValidatorBuilder<...>
input(const loginSchema: z.ZodObject<{ email: z.ZodString; password: z.ZodString;}, "strip", z.ZodTypeAny, { email: string; password: string;}, { email: string; password: string;}>
loginSchema) .FluentValidatorBuilder<ZodObject<{ email: ZodString; password: ZodString; }, "strip", ZodTypeAny, { email: string; password: string; }, { email: string; password: string; }>, {}, {}, "not-required">.rule<ErrorBagFromInput<{ email: string; password: string;}> | { context: { user: User; };}>(rule: ContextRuleDefinition<{ email: string; password: string;}, {}, {}, ErrorBagFromInput<{ email: string; password: string;}> | { context: { user: User; };}>): FluentValidatorBuilder<...>
rule({ fn: ContextRuleFunction<{ email: string; password: string;}, {}, {}, ErrorBagFromInput<{ email: string; password: string;}> | { context: { user: User; };}>
fn: async ({ data: { email: string; password: string;}
data, bag: ErrorBagFromInput<{ email: string; password: string;}>
bag }) => { // Data is typed to the schema output const const user: User | null
user = await const userService: { findByEmail(email: string): Promise<User | null>; validatePassword(user: User, password: string): Promise<boolean>; generateToken(user: User): Promise<string>;}
userService.function findByEmail(email: string): Promise<User | null>
findByEmail(data: { email: string; password: string;}
data.email: string
email); if (!const user: User | null
user) { // The bag is where you put validation errors. // There is a global error (overall reason why the validation failed) // and there are field errors (errors for specific fields) return bag: ErrorBagFromInput<{ email: string; password: string;}>
bag.ErrorBag<requiredKeys<baseObjectOutputType<{ email: ZodString; password: ZodString; }>>>.addGlobalError(message: string): ErrorBagFromInput<{ email: string; password: string;}>
addGlobalError("Invalid email or password"); } if (!(await const userService: { findByEmail(email: string): Promise<User | null>; validatePassword(user: User, password: string): Promise<boolean>; generateToken(user: User): Promise<string>;}
userService.function validatePassword(user: User, password: string): Promise<boolean>
validatePassword(const user: User
user, data: { email: string; password: string;}
data.password: string
password))) { return bag: ErrorBagFromInput<{ email: string; password: string;}>
bag.ErrorBag<requiredKeys<baseObjectOutputType<{ email: ZodString; password: ZodString; }>>>.addError(key: requiredKeys<z.baseObjectOutputType<{ email: z.ZodString; password: z.ZodString;}>>, message: string): ErrorBagFromInput<{ email: string; password: string;}>
addError("password", "Invalid email or password"); } // Now we are returning a user inside a context object. // This allows the next rule and command handler to access // the user object. The types are flowing through our pipeline return { context: { user: User;}
context: { user: User
user } }; }, }) .FluentValidatorBuilder<ZodObject<{ email: ZodString; password: ZodString; }, "strip", ZodTypeAny, { email: string; password: string; }, { email: string; password: string; }>, {}, { ...; }, "not-required">.rule<ErrorBagFromInput<{ email: string; password: string;}> | undefined>(rule: ContextRuleDefinition<{ email: string; password: string;}, {}, { user: User;}, ErrorBagFromInput<{ email: string; password: string;}> | undefined>): FluentValidatorBuilder<...>
rule({ id?: string
id: "named-rule", description?: string
description: "This is a named rule", fn: ContextRuleFunction<{ email: string; password: string;}, {}, { user: User;}, ErrorBagFromInput<{ email: string; password: string;}> | undefined>
fn: async ({ data: { email: string; password: string;}
data, bag: ErrorBagFromInput<{ email: string; password: string;}>
bag, context: { user: User;}
context }) => { if (context: { user: User;}
context.user: User
user.User.email: string
email.String.endsWith(searchString: string, endPosition?: number): boolean
Returns true if the sequence of elements of searchString converted to a String is the
same as the corresponding elements of this object (converted to a String) starting at
endPosition – length(this). Otherwise returns false.
endsWith("@blocked.com")) { return bag: ErrorBagFromInput<{ email: string; password: string;}>
bag.ErrorBag<requiredKeys<baseObjectOutputType<{ email: ZodString; password: ZodString; }>>>.addError(key: requiredKeys<z.baseObjectOutputType<{ email: z.ZodString; password: z.ZodString;}>>, message: string): ErrorBagFromInput<{ email: string; password: string;}>
addError("email", "This email is blocked"); } if (context: { user: User;}
context.user: User
user.User.role: "admin" | "customer"
role === "admin") { // You can also add errors to specific fields (typesafe). return bag: ErrorBagFromInput<{ email: string; password: string;}>
bag.ErrorBag<requiredKeys<baseObjectOutputType<{ email: ZodString; password: ZodString; }>>>.addError(key: requiredKeys<z.baseObjectOutputType<{ email: z.ZodString; password: z.ZodString;}>>, message: string): ErrorBagFromInput<{ email: string; password: string;}>
addError("email", "Admin users cannot login with password"); } }, }) .FluentValidatorBuilder<ZodObject<{ email: ZodString; password: ZodString; }, "strip", ZodTypeAny, { email: string; password: string; }, { email: string; password: string; }>, {}, { ...; }, "not-required">.command<{ user: User; token: string;}>(args: { execute: (params: { data: { email: string; password: string; }; deps: {}; context: { user: User; }; bag: ErrorBagFromSchema<z.ZodObject<{ email: z.ZodString; password: z.ZodString; }, "strip", z.ZodTypeAny, { email: string; password: string; }, { ...; }>>; }) => { ...; } | Promise<...>;}): Command<...>
command({ // Handler to actually login once everything is valid execute: (params: { data: { email: string; password: string; }; deps: {}; context: { user: User; }; bag: ErrorBagFromSchema<z.ZodObject<{ email: z.ZodString; password: z.ZodString; }, "strip", z.ZodTypeAny, { email: string; password: string; }, { ...; }>>;}) => { ...;} | Promise<...>
execute: async ({ context: { user: User;}
context, bag: ErrorBagFromSchema<z.ZodObject<{ email: z.ZodString; password: z.ZodString;}, "strip", z.ZodTypeAny, { email: string; password: string;}, { email: string; password: string;}>>
bag }) => { // We access the user object from the context - // no need to query the database again, we know it's // the same user we retrieved in the previous rule const { const user: User
user } = context: { user: User;}
context; return { user: User
user, token: string
token: await const userService: { findByEmail(email: string): Promise<User | null>; validatePassword(user: User, password: string): Promise<boolean>; generateToken(user: User): Promise<string>;}
userService.function generateToken(user: User): Promise<string>
generateToken(const user: User
user), }; }, });
// Type that can be used around our repotype type LoginResult = { success: true; result: { user: User; token: string; }; context: { user: User; };} | { success: false; errors: ErrorBagFromInput<{ email: string; password: string; }>; step: "validation" | "execution"; rule?: { id?: string; description?: string; };}
LoginResult = type InferCommandResult<TCommand extends Command<any, any, any, any, any>, TCondition extends "success" | "failure" | "all" = "all"> = TCommand extends Command<infer TSchema extends StandardSchemaV1<unknown, unknown>, any, infer TContext, infer TOutput, any> ? TCondition extends "success" ? { ...;} & { ...;} : TCondition extends "failure" ? { ...;} & { ...;} : CommandResult<...> : never
InferCommandResult<typeof const loginCommand: Command<z.ZodObject<{ email: z.ZodString; password: z.ZodString;}, "strip", z.ZodTypeAny, { email: string; password: string;}, { email: string; password: string;}>, {}, { user: User;}, { ...;}, "not-required">
loginCommand>;type type LoginResultSuccess = { success: true; result: { user: User; token: string; }; context: { user: User; };} & { success: true;}
LoginResultSuccess = type InferCommandResult<TCommand extends Command<any, any, any, any, any>, TCondition extends "success" | "failure" | "all" = "all"> = TCommand extends Command<infer TSchema extends StandardSchemaV1<unknown, unknown>, any, infer TContext, infer TOutput, any> ? TCondition extends "success" ? { ...;} & { ...;} : TCondition extends "failure" ? { ...;} & { ...;} : CommandResult<...> : never
InferCommandResult<typeof const loginCommand: Command<z.ZodObject<{ email: z.ZodString; password: z.ZodString;}, "strip", z.ZodTypeAny, { email: string; password: string;}, { email: string; password: string;}>, {}, { user: User;}, { ...;}, "not-required">
loginCommand, "success">;
Integration Examples
Now that we have combined everything into a Command
we can have a simple endpoint that looks like this:
const app: { post: (path: string, handler: (req: { body: unknown; }, res: { status: (code: number) => { json: (body: unknown) => void; }; }) => Promise<unknown>) => void;}
app.post: (path: string, handler: (req: { body: unknown;}, res: { status: (code: number) => { json: (body: unknown) => void; };}) => Promise<unknown>) => void
post("/login", async (req: { body: unknown;}
req, res: { status: (code: number) => { json: (body: unknown) => void; };}
res) => { const const result: CommandResult<{ user: User; token: string;}, { email: string; password: string;}, {}>
result = await const loginCommand: Command<ZodObject<{ email: ZodString; password: ZodString;}, "strip", ZodTypeAny, { email: string; password: string;}, { email: string; password: string;}>, {}, {}, { user: User; token: string;}, "not-required">
loginCommand.Command<ZodObject<{ email: ZodString; password: ZodString; }, "strip", ZodTypeAny, { email: string; password: string; }, { email: string; password: string; }>, {}, {}, { ...; }, "not-required">.run: (input: unknown, opts?: ValidationOpts<{ email: string; password: string;}> | undefined) => Promise<CommandResult<{ user: User; token: string;}, { email: string; password: string;}, {}>>
run(req: { body: unknown;}
req.body: unknown
body); if (!const result: CommandResult<{ user: User; token: string;}, { email: string; password: string;}, {}>
result.success: boolean
success) { return res: { status: (code: number) => { json: (body: unknown) => void; };}
res.status: (code: number) => { json: (body: unknown) => void;}
status(400).json: (body: unknown) => void
json({ success: boolean
success: false, errors: { global: string | undefined; issues: Partial<Record<requiredKeys<{ email: string; password: string; }>, string[]>>;}
errors: const result: { success: false; errors: ErrorBagFromInput<{ email: string; password: string; }>; step: "validation" | "execution"; rule?: { id?: string; description?: string; };}
result.errors: ErrorBagFromInput<{ email: string; password: string;}>
errors.ErrorBag<requiredKeys<{ email: string; password: string; }>>.toObject(): { global: string | undefined; issues: Partial<Record<requiredKeys<{ email: string; password: string; }>, string[]>>;}
toObject(), }); } return res: { status: (code: number) => { json: (body: unknown) => void; };}
res.status: (code: number) => { json: (body: unknown) => void;}
status(200).json: (body: unknown) => void
json({ success: boolean
success: true, result: { user: User; token: string;}
result: const result: { success: true; result: { user: User; token: string; }; context: {};}
result.result: { user: User; token: string;}
result, });});
But that’s not all. It’s easy to see how we can test this command - it’s decoupled from any HTTP context or framework. It’s just a schema and functions. We can use the same command in a CLI interface our app has:
const program: Command
program .Command.command(nameAndArgs: string, opts?: CommandOptions): Command (+1 overload)
Define a command, implemented using an action handler.
command("get:login-token") .Command.option(flags: string, description?: string, defaultValue?: string | boolean | string[]): Command (+2 overloads)
Define option with flags
, description
, and optional argument parsing function or defaultValue
or both.
The flags
string contains the short and/or long flags, separated by comma, a pipe or space. A required
option-argument is indicated by <>
and an optional option-argument by []
.
See the README for more details, and see also addOption() and requiredOption().
option("-e, --email <email>", "The email of the user") .Command.option(flags: string, description?: string, defaultValue?: string | boolean | string[]): Command (+2 overloads)
Define option with flags
, description
, and optional argument parsing function or defaultValue
or both.
The flags
string contains the short and/or long flags, separated by comma, a pipe or space. A required
option-argument is indicated by <>
and an optional option-argument by []
.
See the README for more details, and see also addOption() and requiredOption().
option("-p, --password <password>", "The password of the user") .Command.action(fn: (this: Command, ...args: any[]) => void | Promise<void>): Command
Register callback fn
for the command.
action(async (options: { email: string; password: string;}
options: { email: string
email: string; password: string
password: string }) => { const const result: CommandResult<{ user: User; token: string;}, { email: string; password: string;}, {}>
result = await const loginCommand: Command<ZodObject<{ email: ZodString; password: ZodString;}, "strip", ZodTypeAny, { email: string; password: string;}, { email: string; password: string;}>, {}, {}, { user: User; token: string;}, "not-required">
loginCommand.Command<ZodObject<{ email: ZodString; password: ZodString; }, "strip", ZodTypeAny, { email: string; password: string; }, { email: string; password: string; }>, {}, {}, { ...; }, "not-required">.run: (input: unknown, opts?: ValidationOpts<{ email: string; password: string;}> | undefined) => Promise<CommandResult<{ user: User; token: string;}, { email: string; password: string;}, {}>>
run({ email: string
email: options: { email: string; password: string;}
options.email: string
email, password: string
password: options: { email: string; password: string;}
options.password: string
password, }); if (!const result: CommandResult<{ user: User; token: string;}, { email: string; password: string;}, {}>
result.success: boolean
success) { var console: Console
console.Console.error(...data: any[]): void
error(const result: { success: false; errors: ErrorBagFromInput<{ email: string; password: string; }>; step: "validation" | "execution"; rule?: { id?: string; description?: string; };}
result.errors: ErrorBagFromInput<{ email: string; password: string;}>
errors.ErrorBag<requiredKeys<{ email: string; password: string; }>>.toObject(): { global: string | undefined; issues: Partial<Record<requiredKeys<{ email: string; password: string; }>, string[]>>;}
toObject()); const process: { exit(code: number): never;}
process.function exit(code: number): never
exit(1); } // TypeScript knows result.success is true here, so result.result exists var console: Console
console.Console.log(...data: any[]): void
log(`Login token: ${const result: { success: true; result: { user: User; token: string; }; context: {};}
result.result: { user: User; token: string;}
result.token: string
token}`); });
When errors occur, you get detailed information of what failed, related to which field, and if you defined a rule ID, you know which rule failed as well:
// Example of validation failureconst const result: CommandResult<{ user: User; token: string;}, { email: string; password: string;}, {}>
result = await const loginCommand: Command<ZodObject<{ email: ZodString; password: ZodString;}, "strip", ZodTypeAny, { email: string; password: string;}, { email: string; password: string;}>, {}, {}, { user: User; token: string;}, "not-required">
loginCommand.Command<ZodObject<{ email: ZodString; password: ZodString; }, "strip", ZodTypeAny, { email: string; password: string; }, { email: string; password: string; }>, {}, {}, { ...; }, "not-required">.run: (input: unknown, opts?: ValidationOpts<{ email: string; password: string;}> | undefined) => Promise<CommandResult<{ user: User; token: string;}, { email: string; password: string;}, {}>>
run({ password: string
password: "password123",});if (!const result: CommandResult<{ user: User; token: string;}, { email: string; password: string;}, {}>
result.success: boolean
success) { var console: Console
console.Console.log(...data: any[]): void
log(const result: { success: false; errors: ErrorBagFromInput<{ email: string; password: string; }>; step: "validation" | "execution"; rule?: { id?: string; description?: string; };}
result.step: "validation" | "execution"
step); // "validation" var console: Console
console.Console.log(...data: any[]): void
log(const result: { success: false; errors: ErrorBagFromInput<{ email: string; password: string; }>; step: "validation" | "execution"; rule?: { id?: string; description?: string; };}
result.rule?: { id?: string; description?: string;} | undefined
rule?.id?: string | undefined
id); // "named-rule" var console: Console
console.Console.log(...data: any[]): void
log(const result: { success: false; errors: ErrorBagFromInput<{ email: string; password: string; }>; step: "validation" | "execution"; rule?: { id?: string; description?: string; };}
result.rule?: { id?: string; description?: string;} | undefined
rule?.description?: string | undefined
description); // "This is a named rule" var console: Console
console.Console.log(...data: any[]): void
log(const result: { success: false; errors: ErrorBagFromInput<{ email: string; password: string; }>; step: "validation" | "execution"; rule?: { id?: string; description?: string; };}
result.errors: ErrorBagFromInput<{ email: string; password: string;}>
errors.ErrorBag<requiredKeys<{ email: string; password: string; }>>.firstError(key: requiredKeys<{ email: string; password: string;}>): string | undefined
firstError("email")); // "This email is blocked" // Multiple error formats available var console: Console
console.Console.log(...data: any[]): void
log(const result: { success: false; errors: ErrorBagFromInput<{ email: string; password: string; }>; step: "validation" | "execution"; rule?: { id?: string; description?: string; };}
result.errors: ErrorBagFromInput<{ email: string; password: string;}>
errors.ErrorBag<requiredKeys<{ email: string; password: string; }>>.toObject(): { global: string | undefined; issues: Partial<Record<requiredKeys<{ email: string; password: string; }>, string[]>>;}
toObject()); // { global: undefined, email: ["This email is blocked"] }}
The library also supports type-safe dependency injection and rule composition, allowing you to build reusable validation pipelines. Check out the GitHub repository for more advanced examples and complete documentation.
Conclusion
Business logic validation is hard. What starts as simple if-statements, quickly evolves into a tangled mess as requirements grow - rules need to share data, call external services, provide meaningful errors, need to be tested and understood, and so on.
If you’re using Typescript, you might find this library useful. It’s still early days, but I like how it’s shaping up.