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 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.ZodString
export 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.ZodString
export 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 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: 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.

@example

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

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

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

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

@returnsthis 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
});

@returnsthis command for chaining

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 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: string
email
: "[email protected]",
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.