Generators & Do Notation
@hex-di/result provides two powerful patterns for sequential operations: generator-based flow with safeTry and Do notation for building up context step-by-step.
Generator-Based Flow with safeTry
Overview
safeTry enables linear, imperative-style code for sequential Result operations. Each yield* unwraps an Ok value or short-circuits on Err.
function safeTry<T, E>(
generator: () => Generator<Result<unknown, E>, Result<T, E>, unknown>
): Result<T, E>;
Basic Usage
import { safeTry, ok, err } from "@hex-di/result";
const result = safeTry(function* () {
const a = yield* ok(10); // Unwraps to 10
const b = yield* ok(20); // Unwraps to 20
return ok(a + b); // Must return a Result
});
// result = Ok(30)
const failed = safeTry(function* () {
const a = yield* ok(10); // Unwraps to 10
const b = yield* err("!"); // Short-circuits here
return ok(a + b); // Never reached
});
// failed = Err('!')
Real-World Example: Parsing User Input
import { safeTry, ok, err, fromNullable, type Result } from "@hex-di/result";
interface User {
id: string;
name: string;
email: string;
age: number;
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null;
}
function parseUser(raw: unknown): Result<User, string> {
return safeTry(function* () {
// Validate input is an object
if (!isRecord(raw)) {
return err("Input must be an object");
}
// Extract and validate each field
const id = yield* fromNullable(
typeof raw.id === "string" ? raw.id : undefined,
"Missing or invalid id"
);
const name = yield* fromNullable(
typeof raw.name === "string" ? raw.name : undefined,
"Missing or invalid name"
);
const email = yield* fromNullable(
typeof raw.email === "string" && raw.email.includes("@") ? raw.email : undefined,
"Missing or invalid email"
);
const ageStr = yield* fromNullable(
typeof raw.age === "number" ? raw.age : undefined,
"Missing or invalid age"
);
// Additional validation
if (ageStr < 0 || ageStr > 150) {
return err("Age must be between 0 and 150");
}
return ok({
id,
name,
email,
age: ageStr,
});
});
}
// Usage
const input = { id: "1", name: "Alice", email: "alice@example.com", age: 25 };
const user = parseUser(input);
// Ok({ id: '1', name: 'Alice', email: 'alice@example.com', age: 25 })
const invalid = parseUser({ id: "1" });
// Err('Missing or invalid name')
Complex Business Logic
import { safeTry, ok, err, type Result } from "@hex-di/result";
interface Account {
id: string;
balance: number;
}
interface Transaction {
id: string;
from: string;
to: string;
amount: number;
timestamp: Date;
}
class BankingService {
transfer(fromId: string, toId: string, amount: number): Result<Transaction, string> {
return safeTry(
function* () {
// Validate amount
if (amount <= 0) {
return err("Amount must be positive");
}
// Load accounts
const fromAccount = yield* this.loadAccount(fromId);
const toAccount = yield* this.loadAccount(toId);
// Check balance
if (fromAccount.balance < amount) {
return err("Insufficient funds");
}
// Check daily limit
const dailyTotal = yield* this.getDailyTransferTotal(fromId);
if (dailyTotal + amount > 10000) {
return err("Daily transfer limit exceeded");
}
// Check fraud
const fraudCheck = yield* this.checkFraud(fromId, toId, amount);
if (!fraudCheck) {
return err("Transaction flagged as suspicious");
}
// Perform transfer
const transaction = yield* this.executeTransfer(fromAccount, toAccount, amount);
// Send notifications
yield* this.notifyUser(fromId, `Sent $${amount}`);
yield* this.notifyUser(toId, `Received $${amount}`);
return ok(transaction);
}.bind(this)
);
}
private loadAccount(id: string): Result<Account, string> {
// Implementation
}
private getDailyTransferTotal(accountId: string): Result<number, string> {
// Implementation
}
private checkFraud(from: string, to: string, amount: number): Result<boolean, string> {
// Implementation
}
private executeTransfer(from: Account, to: Account, amount: number): Result<Transaction, string> {
// Implementation
}
private notifyUser(userId: string, message: string): Result<void, string> {
// Implementation
}
}
Do Notation
Do notation provides a way to build up a context object step-by-step, similar to Haskell's do-notation or Scala's for-comprehensions.
bind(name, f)
Adds a named Result value to the context. Short-circuits on Err.
function bind<N extends string, Ctx extends Record<string, unknown>, T, E>(
name: Exclude<N, keyof Ctx>,
f: (ctx: Ctx) => Result<T, E>
): (ctx: Ctx) => Result<Ctx & { readonly [K in N]: T }, E>;
let_(name, f)
Adds a non-Result computed value to the context. Never short-circuits.
function let_<N extends string, Ctx extends Record<string, unknown>, T>(
name: Exclude<N, keyof Ctx>,
f: (ctx: Ctx) => T
): (ctx: Ctx) => Result<Ctx & { readonly [K in N]: T }, never>;
Basic Do Notation Example
import { ok, err, bind, let_ } from "@hex-di/result";
const result = ok({} as Record<string, never>)
.andThen(bind("x", () => ok(10)))
.andThen(bind("y", () => ok(20)))
.andThen(let_("sum", ({ x, y }) => x + y))
.andThen(bind("z", ({ sum }) => (sum > 25 ? ok(sum * 2) : err("Sum too small"))))
.map(({ x, y, sum, z }) => ({
inputs: [x, y],
sum,
result: z,
}));
// Ok({ inputs: [10, 20], sum: 30, result: 60 })
Type-Safe Context Building
The name parameter must be unique — TypeScript enforces this at compile time:
const result = ok({} as Record<string, never>)
.andThen(bind("user", () => fetchUser("123")))
.andThen(bind("user", () => ok("duplicate"))); // TypeScript Error!
// Error: Argument of type '"user"' is not assignable to parameter
Real-World Do Notation Example
import { ok, err, bind, let_, type Result } from "@hex-di/result";
interface OrderRequest {
userId: string;
items: Array<{ productId: string; quantity: number }>;
couponCode?: string;
}
interface OrderResult {
orderId: string;
total: number;
discount: number;
estimatedDelivery: Date;
}
class OrderService {
processOrder(request: OrderRequest): Result<OrderResult, string> {
return (
ok({} as Record<string, never>)
// Load user
.andThen(bind("user", () => this.userService.getUser(request.userId)))
// Check if user is eligible
.andThen(
bind("eligible", ({ user }) =>
user.isActive ? ok(true) : err("User account is not active")
)
)
// Load products
.andThen(bind("products", () => this.loadProducts(request.items.map(i => i.productId))))
// Calculate subtotal
.andThen(
let_("subtotal", ({ products }) =>
request.items.reduce((sum, item) => {
const product = products.find(p => p.id === item.productId);
return sum + (product?.price || 0) * item.quantity;
}, 0)
)
)
// Apply coupon if provided
.andThen(
bind("discount", ({ subtotal }) =>
request.couponCode ? this.applyCoupon(request.couponCode, subtotal) : ok(0)
)
)
// Calculate total
.andThen(let_("total", ({ subtotal, discount }) => Math.max(0, subtotal - discount)))
// Check inventory
.andThen(bind("available", () => this.checkInventory(request.items)))
// Create order
.andThen(
bind("order", ({ user, total, discount }) =>
this.createOrder({
userId: user.id,
items: request.items,
total,
discount,
})
)
)
// Calculate delivery
.andThen(let_("estimatedDelivery", ({ user }) => this.calculateDelivery(user.address)))
// Return final result
.map(({ order, total, discount, estimatedDelivery }) => ({
orderId: order.id,
total,
discount,
estimatedDelivery,
}))
);
}
private userService: UserService;
private loadProducts(ids: string[]): Result<Product[], string> {
// Implementation
}
private applyCoupon(code: string, amount: number): Result<number, string> {
// Implementation
}
private checkInventory(items: OrderItem[]): Result<boolean, string> {
// Implementation
}
private createOrder(data: CreateOrderData): Result<Order, string> {
// Implementation
}
private calculateDelivery(address: Address): Date {
// Implementation
}
}
When to Use Each Pattern
Use Generators (safeTry) when:
- You have sequential operations where each step depends on the previous
- The logic is mostly linear with occasional early returns
- You're parsing or validating complex nested data
- You prefer imperative-style code
- Each operation is relatively simple
Use Do Notation when:
- You're building up a complex context object
- Multiple values need to be accessible throughout the computation
- You want type-safe access to all intermediate values
- You're composing many small functions
- The final result combines multiple intermediate values
Use Method Chaining when:
- The pipeline is simple and linear
- Each step transforms the previous value
- You don't need access to intermediate values
- The logic fits naturally into map/andThen/orElse patterns
Combining Patterns
You can combine these patterns for maximum flexibility:
import { safeTry, ok, err, bind, let_ } from "@hex-di/result";
function complexOperation(input: Input): Result<Output, string> {
// Use Do notation for context building
const context = ok({} as Record<string, never>)
.andThen(bind("config", () => loadConfig()))
.andThen(bind("user", () => authenticateUser(input.token)));
// Use generators for complex logic
return context.andThen(ctx =>
safeTry(function* () {
const permissions = yield* checkPermissions(ctx.user);
const data = yield* fetchData(input.query);
if (!permissions.includes("read")) {
return err("Insufficient permissions");
}
const processed = yield* processData(data, ctx.config);
const validated = yield* validateOutput(processed);
return ok({
user: ctx.user.name,
data: validated,
timestamp: new Date(),
});
})
);
}