Business Logic Validation in Typescript

• By Javier López

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 Diagram

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:

  1. Safely provide validation errors that stop the flow of execution when business rules are violated
  2. 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 zz } 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 User { User.id: stringid: string; User.role: "admin" | "customer"role: "admin" | "customer"; User.email: stringemail: string; User.passwordHash: stringpasswordHash: 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: stringemail: string): interface Promise<T>
Represents the completion of an asynchronous operation
Promise
<User | null>;
function validatePassword(user: User, password: string): Promise<boolean>validatePassword(user: Useruser: User, password: stringpassword: string): interface Promise<T>
Represents the completion of an asynchronous operation
Promise
<boolean>;
function generateToken(user: User): Promise<string>generateToken(user: Useruser: 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 zz.
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.ZodStringemail: import zz.
function string(params?: z.RawCreateParams & {
    coerce?: true;
}): z.ZodString
export string
string
().ZodString.email(message?: errorUtil.ErrMessage): z.ZodStringemail(),
password: z.ZodStringpassword: import zz.
function string(params?: z.RawCreateParams & {
    coerce?: true;
}): z.ZodString
export string
string
().ZodString.min(minLength: number, message?: errorUtil.ErrMessage): z.ZodStringmin(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 | nulluser = 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: stringemail);
if (!const user: User | nulluser) { // 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: Useruser,
data: {
    email: string;
    password: string;
}
data
.password: stringpassword))) {
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: Useruser } };
}, }) .
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 | undefinedid: "named-rule", description?: string | undefineddescription: "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: Useruser.User.email: stringemail.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: Useruser.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: Useruser } =
context: {
    user: User;
}
context
;
return { user: Useruser, token: stringtoken: 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: Useruser),
}; }, }); // Type that can be used around our repo type
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: unknownbody);
if (!
const result: CommandResult<{
    user: User;
    token: string;
}, {
    email: string;
    password: string;
}, {}>
result
.success: booleansuccess) {
return
res: {
    status: (code: number) => {
        json: (body: unknown) => void;
    };
}
res
.
status: (code: number) => {
    json: (body: unknown) => void;
}
status
(400).json: (body: unknown) => voidjson({
success: booleansuccess: 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) => voidjson({
success: booleansuccess: 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: Commandprogram
  .Command.command(nameAndArgs: string, opts?: CommandOptions): Command (+1 overload)
Define a command, implemented using an action handler.
@remarksThe command description is supplied using `.description`, not as a parameter to `.command`.@example```ts program .command('clone <source> [destination]') .description('clone a repository into a newly created directory') .action((source, destination) => { console.log('clone command called'); }); ```@paramnameAndArgs - command name and arguments, args are `<required>` or `[optional]` and last may also be `variadic...`@paramopts - configuration options@returnsnew command
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().
@example```js program .option('-p, --pepper', 'add pepper') .option('--pt, --pizza-type <TYPE>', 'type of pizza') // required option-argument .option('-c, --cheese [CHEESE]', 'add extra cheese', 'mozzarella') // optional option-argument with default .option('-t, --tip <VALUE>', 'add tip to purchase cost', parseFloat) // custom parse function ```@returns`this` command for chaining
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().
@example```js program .option('-p, --pepper', 'add pepper') .option('--pt, --pizza-type <TYPE>', 'type of pizza') // required option-argument .option('-c, --cheese [CHEESE]', 'add extra cheese', 'mozzarella') // optional option-argument with default .option('-t, --tip <VALUE>', 'add tip to purchase cost', parseFloat) // custom parse function ```@returns`this` command for chaining
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.
@example``` program .command('serve') .description('start service') .action(function() { // do work here }); ```@returns`this` command for chaining
action
(async (
options: {
    email: string;
    password: string;
}
options
: { email: stringemail: string; password: stringpassword: 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: stringemail:
options: {
    email: string;
    password: string;
}
options
.email: stringemail,
password: stringpassword:
options: {
    email: string;
    password: string;
}
options
.password: stringpassword,
}); if (!
const result: CommandResult<{
    user: User;
    token: string;
}, {
    email: string;
    password: string;
}, {}>
result
.success: booleansuccess) {
var console: Consoleconsole.Console.error(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static)
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): neverexit(1);
} // TypeScript knows result.success is true here, so result.result exists var console: Consoleconsole.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)
log
(`Login token: ${
const result: {
    success: true;
    result: {
        user: User;
        token: string;
    };
    context: {};
}
result
.
result: {
    user: User;
    token: string;
}
result
.token: stringtoken}`);
});

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 failure
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: stringemail: "admin@blocked.com", password: stringpassword: "password123", }); if (!
const result: CommandResult<{
    user: User;
    token: string;
}, {
    email: string;
    password: string;
}, {}>
result
.success: booleansuccess) {
var console: Consoleconsole.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)
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: Consoleconsole.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)
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 | undefinedid); // "named-rule"
var console: Consoleconsole.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)
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 | undefineddescription); // "This is a named rule"
var console: Consoleconsole.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)
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: Consoleconsole.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)
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.