Skip to main content

Overview

Result.gen() enables imperative-style error handling using JavaScript generators and yield* syntax:
  • Write code that looks synchronous but short-circuits on first error
  • Automatic error type union inference across multiple yields
  • Support for finally blocks, using declarations, and async operations
  • Type-safe alternative to try-catch and promise chaining

Basic Usage

Simple Composition

const getA = (): Result<number, string> => Result.ok(1);
const getB = (a: number): Result<number, string> => Result.ok(a + 1);
const getC = (b: number): Result<number, string> => Result.ok(b + 1);

const result = Result.gen(function* () {
  const a = yield* getA();  // Unwraps Ok(1) -> a = 1
  const b = yield* getB(a); // Unwraps Ok(2) -> b = 2
  const c = yield* getC(b); // Unwraps Ok(3) -> c = 3
  return Result.ok(c);
});
// Result<number, string> = Ok(3)

Short-Circuiting on Error

When any yield* encounters an Err, execution stops immediately:
class ErrorA extends TaggedError("ErrorA")<{ message: string }>() {}
class ErrorB extends TaggedError("ErrorB")<{ message: string }>() {}

const result = Result.gen(function* () {
  const a = yield* Result.ok(1);                     // a = 1
  const b = yield* Result.err(new ErrorA({ message: "failed" })); // Stops here!
  const c = yield* Result.ok(3);                     // Never executed
  return Result.ok(a + b + c);
});
// Result<number, ErrorA> = Err(ErrorA)
This is railway-oriented programming: operations proceed on the “success track” until an error switches to the “error track”.

How It Works

The yield* Operator

yield* delegates to the iterator protocol. Ok and Err implement [Symbol.iterator]:
// From result.ts
class Ok<A, E> {
  *[Symbol.iterator](): Generator<Err<never, E>, A, unknown> {
    return this.value; // Immediately returns without yielding
  }
}

class Err<T, E> {
  *[Symbol.iterator](): Generator<Err<never, E>, never, unknown> {
    yield this as unknown as Err<never, E>; // Yields error, stops execution
    return panic("Unreachable", this.error);
  }
}
When Result.gen() encounters a yielded Err, it stops the generator and returns that error.

Type Inference

TypeScript infers the union of all yielded error types:
class ErrorA extends TaggedError("ErrorA")<{ message: string }>() {}
class ErrorB extends TaggedError("ErrorB")<{ message: string }>() {}
class ErrorC extends TaggedError("ErrorC")<{ message: string }>() {}

const getA = (): Result<number, ErrorA> => Result.ok(1);
const getB = (): Result<number, ErrorB> => Result.ok(2);
const getC = (): Result<number, ErrorC> => Result.ok(3);

const result = Result.gen(function* () {
  const a = yield* getA(); // Can fail with ErrorA
  const b = yield* getB(); // Can fail with ErrorB
  const c = yield* getC(); // Can fail with ErrorC
  return Result.ok(a + b + c);
});
// Result<number, ErrorA | ErrorB | ErrorC>

Comparison to Other Patterns

vs Try-Catch

try {
  const user = await fetchUser(id);
  const validated = await validateUser(user);
  const saved = await saveUser(validated);
  return saved;
} catch (error) {
  // All errors lumped together
  // No type safety on error
  console.error(error);
  throw error;
}

vs Promise Chaining

fetchUser(id)
  .then(user => validateUser(user))
  .then(validated => saveUser(validated))
  .then(saved => {
    console.log("Success:", saved);
    return saved;
  })
  .catch(error => {
    console.error("Error:", error);
    throw error;
  });

vs andThen Chaining

fetchUser(id)
  .andThen(user => validateUser(user))
  .andThen(validated => saveUser(validated))
  .tap(saved => console.log("Success:", saved));
Use andThen() for simple linear chains. Use Result.gen() when you need:
  • Multiple intermediate values
  • Conditional logic
  • Loops or complex control flow
  • Resource cleanup with finally

Async Generators

Result.await()

Wrap Promise<Result> to make it yieldable in async generators:
const fetchUser = (id: string): Promise<Result<User, NotFoundError>> => {
  // ...
};

const result = await Result.gen(async function* () {
  const user = yield* Result.await(fetchUser("123"));
  const profile = yield* Result.await(fetchProfile(user.id));
  return Result.ok({ user, profile });
});

Mixing Sync and Async

const result = await Result.gen(async function* () {
  // Sync operation
  const validated = yield* validateInput(input);
  
  // Async operation
  const user = yield* Result.await(fetchUser(validated.id));
  
  // Another sync operation
  const enriched = yield* enrichUserData(user);
  
  return Result.ok(enriched);
});

Error Propagation

Automatic Error Union

Errors from all yields are automatically unioned:
class NotFoundError extends TaggedError("NotFoundError")<{
  id: string;
  message: string;
}>() {}

class ValidationError extends TaggedError("ValidationError")<{
  field: string;
  message: string;
}>() {}

class DatabaseError extends TaggedError("DatabaseError")<{
  message: string;
  cause: unknown;
}>() {}

const processUser = (id: string): Result<User, NotFoundError | ValidationError | DatabaseError> => {
  return Result.gen(function* () {
    const user = yield* fetchUser(id);        // NotFoundError
    const validated = yield* validateUser(user); // ValidationError
    const saved = yield* saveUser(validated);    // DatabaseError
    return Result.ok(saved);
  });
};

Normalizing Error Types

Use mapError() to convert error unions to a single type:
class AppError extends TaggedError("AppError")<{
  message: string;
  originalTag: string;
}>() {}

const processUser = (id: string): Result<User, AppError> => {
  return Result.gen(function* () {
    const user = yield* fetchUser(id);
    const validated = yield* validateUser(user);
    const saved = yield* saveUser(validated);
    return Result.ok(saved);
  }).mapError(e => new AppError({
    message: e.message,
    originalTag: e._tag,
  }));
};

Resource Cleanup

Finally Blocks

finally blocks run even when short-circuiting:
const result = Result.gen(function* () {
  let resource: Resource | null = null;
  
  try {
    resource = yield* acquireResource();
    const data = yield* processResource(resource);
    return Result.ok(data);
  } finally {
    if (resource) {
      resource.cleanup();
    }
  }
});
If a finally block throws, Result.gen() will throw a Panic. Ensure cleanup code doesn’t throw:
finally {
  // Bad: might throw
  resource.cleanup();
  
  // Good: catch errors
  try {
    resource.cleanup();
  } catch (e) {
    console.error("Cleanup failed:", e);
  }
}

Using Declarations (Resource Management)

TC39 Explicit Resource Management (Stage 3) works with Result.gen():
const result = Result.gen(function* () {
  using resource = yield* acquireResource();
  // resource implements Symbol.dispose
  
  const data = yield* processResource(resource);
  
  // resource.cleanup() called automatically, even on error
  return Result.ok(data);
});

Async Resource Management

const result = await Result.gen(async function* () {
  await using connection = yield* Result.await(connectToDatabase());
  // connection implements Symbol.asyncDispose
  
  const user = yield* Result.await(fetchUser(connection, id));
  
  // connection.close() called automatically
  return Result.ok(user);
});

Complex Control Flow

Conditional Logic

const processOrder = (orderId: string): Result<Order, AppError> => {
  return Result.gen(function* () {
    const order = yield* fetchOrder(orderId);
    
    if (order.status === "pending") {
      const validated = yield* validateOrder(order);
      const processed = yield* processPayment(validated);
      return Result.ok(processed);
    } else if (order.status === "shipped") {
      return Result.err(new OrderAlreadyShippedError({ orderId }));
    } else {
      const refunded = yield* refundOrder(order);
      return Result.ok(refunded);
    }
  });
};

Loops

const processItems = (
  items: string[]
): Result<ProcessedItem[], ProcessError> => {
  return Result.gen(function* () {
    const results: ProcessedItem[] = [];
    
    for (const item of items) {
      const validated = yield* validateItem(item);
      const processed = yield* processItem(validated);
      results.push(processed);
    }
    
    return Result.ok(results);
  });
};
If any yield* in the loop fails, the entire operation stops and returns that error.

Early Returns

const updateUser = (
  id: string,
  data: UserUpdate
): Result<User, AppError> => {
  return Result.gen(function* () {
    const user = yield* fetchUser(id);
    
    // Early return on condition
    if (user.deleted) {
      return Result.err(new UserDeletedError({ id }));
    }
    
    if (!user.emailVerified && data.email) {
      return Result.err(new EmailNotVerifiedError({ id }));
    }
    
    const updated = yield* saveUser({ ...user, ...data });
    return Result.ok(updated);
  });
};

Context Binding

Bind this context with the second parameter:
class UserService {
  constructor(private db: Database) {}
  
  async processUser(id: string): Promise<Result<User, AppError>> {
    return Result.gen(async function* (this: UserService) {
      const user = yield* Result.await(this.db.fetchUser(id));
      const validated = yield* this.validateUser(user);
      return Result.ok(validated);
    }, this);
  }
  
  validateUser(user: User): Result<User, ValidationError> {
    // ...
  }
}

Real-World Examples

User Registration Flow

class ValidationError extends TaggedError("ValidationError")<{
  field: string;
  message: string;
}>() {}

class DuplicateError extends TaggedError("DuplicateError")<{
  email: string;
  message: string;
}>() {}

class EmailError extends TaggedError("EmailError")<{
  message: string;
  cause: unknown;
}>() {}

type RegistrationError = ValidationError | DuplicateError | EmailError;

const registerUser = async (
  input: RegistrationInput
): Promise<Result<User, RegistrationError>> => {
  return Result.gen(async function* () {
    // Validate input
    const validated = yield* validateInput(input);
    
    // Check for existing user
    const existing = yield* Result.await(findUserByEmail(validated.email));
    if (existing.isOk()) {
      return Result.err(new DuplicateError({
        email: validated.email,
        message: "Email already registered",
      }));
    }
    
    // Hash password
    const hashedPassword = yield* hashPassword(validated.password);
    
    // Create user
    const user = yield* Result.await(
      createUser({ ...validated, password: hashedPassword })
    );
    
    // Send welcome email (don't fail registration if this fails)
    const emailResult = yield* Result.await(
      sendWelcomeEmail(user.email)
    );
    if (emailResult.isErr()) {
      console.warn("Failed to send welcome email:", emailResult.error);
    }
    
    return Result.ok(user);
  });
};

Batch Processing with Rollback

const processBatch = async (
  items: Item[]
): Promise<Result<void, BatchError>> => {
  return Result.gen(async function* () {
    const processed: ProcessedItem[] = [];
    
    try {
      for (const item of items) {
        const validated = yield* validateItem(item);
        const result = yield* Result.await(processItem(validated));
        processed.push(result);
      }
      
      // Commit all
      yield* Result.await(commitBatch(processed));
      return Result.ok(undefined);
    } catch (error) {
      // Rollback processed items
      for (const item of processed.reverse()) {
        await rollbackItem(item).catch(console.error);
      }
      throw error;
    }
  });
};

Multi-Step Workflow

const processOrderWorkflow = async (
  orderId: string
): Promise<Result<Receipt, WorkflowError>> => {
  return Result.gen(async function* () {
    // Step 1: Fetch order
    console.log("[1/5] Fetching order...");
    const order = yield* Result.await(fetchOrder(orderId));
    
    // Step 2: Validate inventory
    console.log("[2/5] Validating inventory...");
    const inventory = yield* Result.await(checkInventory(order.items));
    
    // Step 3: Reserve items
    console.log("[3/5] Reserving items...");
    using reservation = yield* Result.await(reserveItems(inventory));
    
    // Step 4: Process payment
    console.log("[4/5] Processing payment...");
    const payment = yield* Result.await(processPayment(order.total));
    
    // Step 5: Generate receipt
    console.log("[5/5] Generating receipt...");
    const receipt = yield* generateReceipt(order, payment);
    
    console.log("✓ Order processed successfully");
    return Result.ok(receipt);
  });
};

Best Practices

The generator body must return Result.ok() or Result.err():
// Good
Result.gen(function* () {
  const x = yield* getX();
  return Result.ok(x * 2);
});

// Bad: returns number directly
Result.gen(function* () {
  const x = yield* getX();
  return x * 2; // Panic!
});
Throwing before any yield* will cause a Panic:
// Bad: throws before yielding
Result.gen(function* () {
  throw new Error("oops"); // Panic!
});

// Good: return error
Result.gen(function* () {
  return Result.err(new MyError({ ... }));
});
Wrap cleanup in try-catch:
Result.gen(function* () {
  try {
    const resource = yield* acquire();
    return Result.ok(resource);
  } finally {
    try {
      cleanup();
    } catch (e) {
      console.error("Cleanup failed:", e);
    }
  }
});
Always wrap Promise<Result> with Result.await():
// Good
const user = yield* Result.await(fetchUser(id));

// Bad: yields Promise, not Result
const user = yield* fetchUser(id); // Type error

Summary

Use Result.gen() when you need:
  • Imperative style - Code that reads like normal sync/async code
  • Multiple values - Access to intermediate results
  • Complex control flow - Conditions, loops, early returns
  • Resource cleanup - finally blocks or using declarations
  • Error union - Automatic inference of all possible error types
Avoid Result.gen() when:
  • Simple linear transformations (use map()/andThen())
  • Purely functional composition (use pipeable API)
  • No intermediate values needed

Next Steps

Creating Results

Learn how to create Result instances with ok, err, try, and tryPromise

Error Handling

Master TaggedError and exhaustive error matching

Build docs developers (and LLMs) love