ts-decider
v1.0.4
Published
TypeScript utilities for implementing the decider pattern
Readme
ts-decider
A minimal, type-safe utility for modeling domain logic using the decider pattern. This pattern is particularly suitable for event-sourced and functional architectures, where domain behavior is expressed as the result of handling commands and applying events.
Installation
npm install ts-deciderOverview
This library encourages structuring business logic as the composition of two pure functions:
decide: maps a command and current state to a list of domain eventsevolve: applies a single event to an aggregate to produce a new state
Failures are modeled explicitly using structured failure events. The focus is on the behavior of your domain, not on parsing, validation, or infrastructure concerns.
Example: Task Management
import { v4 as uuid } from "uuid";
import { Decider, deciderHelpers, FailEvt } from "ts-decider";
// 1 - model your domain object using Types
type Task = {
id: string;
title: string;
completed: boolean;
createdAt: number;
completedAt?: number;
};
// 2- model commands and events describing all state transitions
type TaskCmd =
| { type: "create-task"; data: { title: string } }
| { type: "complete-task"; data: { id: string } }
| { type: "reopen-task"; data: { id: string } };
type TaskEvt =
| { type: "task-created"; data: Task }
| { type: "task-completed"; data: { id: string; completedAt: number } }
| { type: "task-reopened"; data: { id: string } };
// 3 - specify what can go wrong with Failures (optional)
// type TaskFailures = "cannot_reopen_task:task_is_not_completed" | "cannot_complete_task:task_is_already_completed";
// 4 - create a Decider type alias
type TaskDecider = Decider<Task, TaskCmd, TaskEvt, TaskFailures>;
// 5 - generate the Helpers injecting the right types using your decider alias
const h: TaskDecider["helpers"] = deciderHelpers<
TaskDecider["state"],
TaskDecider["cmds"],
TaskDecider["evts"],
//Optional: TaskDecider["fails"]
>();
// 6 - implement a Decide function
const decide: TaskDecider["decide"] = (cmd) => (task) => {
switch (cmd.type) {
case "create-task":
return [{
type: "task-created",
data: {
id: uuid(),
title: cmd.data.title,
completed: false,
createdAt: Date.now()
}
}];
case "complete-task":
if (!task) return [h.failEvt(cmd)("state_is_undefined")];
return [{
type: "task-completed",
data: {
id: cmd.data.id,
completedAt: Date.now()
}
}];
case "reopen-task":
if (!task) return [h.failEvt(cmd)("state_is_undefined")];
return [{
type: "task-reopened",
data: {
id: cmd.data.id
}
}];
default:
return [h.failEvt(cmd)("command_not_found")];
}
};
// 7 - implement an Evolve function
const evolve: TaskDecider["evolve"] = (task) => (evt) => {
switch (evt.type) {
case "task-created":
return evt.data;
case "task-completed":
return task ? { ...task, completed: true, completedAt: evt.data.completedAt } : task;
case "task-reopened":
return task ? { ...task, completed: false, completedAt: undefined } : task;
case "operation-failed":
return task;
}
};
// 8 - apply the decide and evolve functions to the Run helper
export const tasksDecider: TaskDecider["run"] = h.run(decide)(evolve);
// 9 - Use it!
const createResult = tasksDecider({ type: "create-task", data: { title: "Write documentation" } })(undefined);
const completeResult = tasksDecider({ type: "complete-task", data: { id: 'abc' } })(createResult.state);
const failedResult = tasksDecider({ type: "complete-task", data: { id: 'abc' } })(undefined);
console.log('create example:', createResult);
console.log('complete example:', completeResult);
console.log('failed operation example:', failedResult);
/*
create example:
{
state: {
id: 'abc',
title: 'Write documentation',
completed: false,
createdAt: 1720000000000
},
evts: [
{
type: 'task-created',
data: {
id: 'abc',
title: 'Write documentation',
completed: false,
createdAt: 1720000000000
}
}
]
}
complete example:
{
state: {
id: 'abc',
title: 'Write documentation',
completed: true,
createdAt: 1720000000000,
completedAt: 1720000001000
},
evts: [
{
type: 'task-completed',
data: {
id: 'abc',
completedAt: 1720000001000
}
}
]
}
failed operation example:
{
state: undefined,
evts: [
{
type: 'operation-failed',
data: {
cmd: { type: 'complete-task', data: { id: 'abc' } },
reason: 'state_is_undefined'
}
}
]
}
*/
API
Decider<A, C, E, F>
type Decider<A, C, E, F> = {
agg: A | undefined;
cmd: C;
evt: E | FailEvt<C, F>;
fails: F;
decide: (cmd: C) => (agg: A | undefined) => Array<E | FailEvt<C, F>>;
evolve: (agg: A | undefined) => (evt: E | FailEvt<C, F>) => A | undefined;
run: (cmd: C) => (agg: A | undefined) => {
state: A | undefined;
evts: Array<E | FailEvt<C, F>>;
};
helpers: DeciderHelpers<A | undefined, C, E | FailEvt<C, F>, F>;
};deciderHelpers<A, C, E, F>()
Returns:
failEvt(cmd)(reason): constructs a typed failure eventrun(decide)(evolve)(cmd)(agg): executes the decision and state transition
Failure Modeling
When a command cannot be applied — due to missing state, an invalid transition, or an unrecognized input — ts-decider allows you to emit a structured failure event instead of throwing or branching imperatively.
Failures are expressed using a typed FailEvt:
type FailEvt<C, F extends string> = {
type: "operation-failed";
data: {
cmd: C;
reason: F;
};
};By default, ts-decider includes two predefined failure reasons:
type DefaultFails = "cmd_not_found" | "state_is_undefined";You may extend this with your own application-specific reasons by parameterizing the decider with a custom string union:
type MyFailures = "permission_denied" | "quota_exceeded";This allows failure handling code — whether in tests, outbox processors, or monitoring — to match on specific reasons using a switch or conditional:
if (evt.type === "operation-failed") {
switch (evt.data.reason) {
case "permission_denied":
// do something
case "state_is_undefined":
// do something else
}
}Design Considerations
This code is intended to represent core domain logic. It assumes the following architectural constraints:
- Data structure validation and parsing occur outside this layer, typically at the application boundary or transport layer (e.g., API layer, event deserializer).
- Inputs are expected to be well-formed by the time they reach the decider.
- This allows the domain logic to remain focused on business behavior rather than structural correctness.
This approach supports separation of concerns and aligns with architectural styles like functional core/imperative shell, clean architecture, and hexagonal architecture.
Developer Experience
Typing decide and evolve as YourDecider["decide"] and YourDecider["evolve"] provides:
- Autocomplete for all valid
cmd.typeandevt.typecases inswitchstatements - Type narrowing for accessing
cmd.dataorevt.data - Compile-time safety for handling unexpected or incomplete logic
- Better editor feedback when evolving your model or making changes
This reduces cognitive overhead and makes domain modeling easier to evolve safely.
Integration and the Outbox Pattern
The return value of run(cmd)(state) is an object with the final aggregate state and a list of events:
{
state: A | undefined;
evts: Array<E | FailEvt<C, F>>;
}This structure lends itself naturally to outbox-based architectures, allowing flexibility in how you integrate with external systems:
- You can persist the state and enqueue the events in a single transaction.
- You can persist only the events (in fully event-sourced systems), or only the state (in state-based apps).
- You can summarize the outcome before enqueueing notifications or API calls.
Author
Giovanni Chiodi github.com/mean-machine-gc
