Skip to content

Result Pattern ​

Learn how to use explicit error handling with the Result / AsyncResult pattern from unthrown.

Overview ​

temporal-contract uses unthrown's Result<T, E> and AsyncResult<T, E> types throughout its public surface:

  • Activities return AsyncResult<T, ApplicationFailure>.
  • Workflows await activities and child workflows; the framework unwraps the Result for activities (so a workflow sees a plain value or a thrown error) and surfaces Result directly for child workflows.
  • Clients await AsyncResult<T, E>; the resolved value is a Result<T, E> that you destructure with result.match({ ok, err, defect }) or the free functions isOk(result) / isErr(result) / isDefect(result).

A single library covers every context — the same import works inside activities, workflows, and clients.

Installation ​

bash
pnpm add unthrown

AsyncResult<T, E> is awaitable: await asyncResult resolves to Result<T, E>. The underlying Promise is constructed when the chain runs, so the type behaves like a lazy task — call sites that already await the value before checking isOk(result) / isErr(result) need no changes.

TIP

Narrow before touching .value / .error / .cause. Both forms are type guards: the result.isOk() / result.isErr() / result.isDefect()methods and the matching free functions isOk(result) / isErr(result) / isDefect(result) imported from "unthrown". This codebase uses the methods; either works.

Basic Usage ​

Activities ​

Activities return AsyncResult<T, ApplicationFailure>. The cleanest shape is fromPromise(promise, mapError):

typescript
import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity";
import { fromPromise } from "unthrown";
import { orderContract } from "./contract.js";

export const activities = declareActivitiesHandler({
  contract: orderContract,
  activities: {
    processPayment: ({ amount }) =>
      fromPromise(paymentGateway.charge(amount), (error) =>
        ApplicationFailure.create({
          type: "PAYMENT_FAILED",
          message: error instanceof Error ? error.message : "Payment failed",
          cause: error instanceof Error ? error : undefined,
        }),
      ).map((txId) => ({ transactionId: txId, success: true })),

    sendEmail: ({ to, body }) =>
      fromPromise(emailService.send({ to, body }), (error) =>
        ApplicationFailure.create({
          type: "EMAIL_FAILED",
          message: error instanceof Error ? error.message : "Email failed",
          cause: error instanceof Error ? error : undefined,
        }),
      ).map(() => ({ sent: true })),
  },
});

Workflows ​

Workflows await activities and child workflows directly. Activities return plain values inside the workflow body (the framework unwraps the Result); child workflows surface Result so the workflow can branch on success vs. failure without throwing:

typescript
import { declareWorkflow } from "@temporal-contract/worker/workflow";
import { orderContract } from "./contract.js";

export const processOrder = declareWorkflow({
  workflowName: "processOrder",
  contract: orderContract,
  activityOptions: { startToCloseTimeout: "1 minute" },
  implementation: async (context, args) => {
    // Process payment - activities return plain values
    const payment = await context.activities.processPayment({ amount: args.amount });

    // Send confirmation email
    await context.activities.sendEmail({
      to: "customer@example.com",
      body: `Order ${args.orderId} confirmed`,
    });

    return {
      success: true,
      transactionId: payment.transactionId,
    };
  },
});

Clients ​

Clients receive an AsyncResult<T, E> from executeWorkflow / startWorkflow. Awaiting it yields a Result<T, E>:

typescript
import { TypedClient } from "@temporal-contract/client";
import { Client } from "@temporalio/client";
import { orderContract } from "./contract.js";

const temporalClient = new Client({ connection });

const client = await TypedClient.create({
  contract: orderContract,
  client: temporalClient,
}).getOrThrow();
const result = await client.executeWorkflow("processOrder", {
  workflowId: "order-123",
  args: { orderId: "ORD-123", amount: 100 },
});

// Handle result with pattern matching (object form, three channels)
result.match({
  ok: (value) => {
    console.log("Order processed:", value.transactionId);
  },
  err: (error) => {
    console.error("Order failed:", error);
  },
  defect: (cause) => {
    console.error("Unexpected failure:", cause);
  },
});

Awaiting and inspecting ​

AsyncResult<T, E> is a thin wrapper around a Promise<Result<T, E>>. You can await it once and then inspect synchronously, or chain with .map, .mapErr, .flatMap, .flatMapErr before awaiting:

typescript
import { isErr } from "unthrown";

const result = await client.executeWorkflow("processOrder", {
  workflowId: "order-123",
  args: { orderId: "ORD-123", amount: 100 },
});

if (isErr(result)) {
  console.error(result.error);
  return;
}

console.log(result.value);

Pattern Matching ​

Activities return plain values when called from workflows. If an activity fails, the framework rethrows the ApplicationFailure so workflow code can catch it like any other Temporal failure:

typescript
export const processOrder = declareWorkflow({
  workflowName: "processOrder",
  contract: orderContract,
  activityOptions: { startToCloseTimeout: "1 minute" },
  implementation: async (context, args) => {
    try {
      // Activity returns plain value (Result is unwrapped internally)
      const payment = await context.activities.processPayment({ amount: 100 });
      console.log("Payment succeeded:", payment.transactionId);

      return { success: true, transactionId: payment.transactionId };
    } catch (error) {
      // Activity errors are thrown
      console.error("Payment failed:", error);
      return { success: false, transactionId: "" };
    }
  },
});

NOTE

For child workflows, you do get Result objects. See the Child Workflows section below.

Chaining Activities ​

When calling multiple activities, use standard async/await with try/catch:

typescript
export const processOrder = declareWorkflow({
  workflowName: "processOrder",
  contract: orderContract,
  activityOptions: { startToCloseTimeout: "1 minute" },
  implementation: async (context, args) => {
    try {
      // Activities return plain values
      const payment = await context.activities.processPayment({ amount: 100 });

      // Next activity only runs if payment succeeded
      await context.activities.sendEmail({
        to: "customer@example.com",
        body: `Payment ${payment.transactionId} processed`,
      });

      // Update database
      await context.activities.updateDatabase({
        status: "completed",
      });

      return { success: true };
    } catch (error) {
      console.error("Workflow failed:", error);
      return { success: false };
    }
  },
});

Error Types ​

Define typed errors in your activities:

typescript
import { fromPromise } from "unthrown";
import { ApplicationFailure } from "@temporal-contract/worker/activity";

type PaymentError =
  | { type: "InsufficientFunds" }
  | { type: "CardDeclined" }
  | { type: "NetworkError"; message: string };

type EmailError = { type: "InvalidEmail" } | { type: "ServiceUnavailable" };

// Activities return AsyncResult with typed errors
processPayment: ({ amount }) =>
  fromPromise(paymentGateway.charge(amount), (error) => {
    // Wrap domain errors in ApplicationFailure so Temporal applies the
    // configured retry policy; set `nonRetryable: true` for permanent
    // failures.
    return ApplicationFailure.create({
      type: "PAYMENT_FAILED",
      message: error instanceof Error ? error.message : "Payment failed",
      ...(error instanceof Error ? { cause: error } : {}),
    });
  }).map((txId) => ({ transactionId: txId }));

Benefits ​

1. Explicit Error Handling ​

Activities use the AsyncResult pattern internally, while workflows use try/catch:

typescript
import { fromPromise } from "unthrown";

// Activity implementation (uses AsyncResult)
const processPayment = ({ amount }) =>
  fromPromise(paymentGateway.charge(amount), (error) =>
    ApplicationFailure.create({
      type: "PAYMENT_FAILED",
      message: "Payment failed",
      cause: error instanceof Error ? error : undefined,
    }),
  ).map((txId) => ({ transactionId: txId }));

// Workflow (uses standard try/catch for activities)
export const processOrder = declareWorkflow({
  workflowName: "processOrder",
  contract: myContract,
  activityOptions: { startToCloseTimeout: "1 minute" },
  implementation: async (context, args) => {
    try {
      // Activity returns plain value
      const payment = await context.activities.processPayment({ amount: 100 });
      return { success: true, transactionId: payment.transactionId };
    } catch (error) {
      // Handle activity error
      return { success: false };
    }
  },
});

2. No Hidden Exceptions in Activities ​

Activities explicitly return AsyncResult instead of throwing:

typescript
// ✅ Clear - activity returns AsyncResult<T, ApplicationFailure>
const processPayment = ({ amount }) =>
  fromPromise(paymentGateway.charge(amount), (error) =>
    ApplicationFailure.create({
      type: "PAYMENT_FAILED",
      message: "Payment failed",
      cause: error instanceof Error ? error : undefined,
    }),
  ).map((txId) => ({ transactionId: txId }));

// ❌ Unclear - might throw anything
async function processPayment({ amount }) {
  const txId = await paymentGateway.charge(amount);
  return { transactionId: txId };
}

3. Railway-Oriented Programming (Activities) ​

Activity implementations can chain operations that short-circuit on error using .flatMap (unthrown's bind/chain operator):

typescript
// Activity implementation with chaining
const processOrder = ({ orderId }) =>
  validateOrderId(orderId)
    .flatMap((validId) => fetchOrder(validId))
    .flatMap((order) => processPayment(order))
    .flatMap((payment) => updateDatabase(payment))
    .mapErr((error) =>
      ApplicationFailure.create({
        type: "ORDER_FAILED",
        message: "Order processing failed",
        cause: error instanceof Error ? error : undefined,
      }),
    );
// Stops at first error

4. Partial Success Handling ​

Track partial success in complex workflows using try/catch blocks:

typescript
export const processOrder = declareWorkflow({
  workflowName: "processOrder",
  contract: orderContract,
  activityOptions: { startToCloseTimeout: "1 minute" },
  implementation: async (context, args) => {
    let paymentTransactionId: string | undefined;

    try {
      // Step 1: Process payment
      const payment = await context.activities.processPayment({ amount: args.amount });
      paymentTransactionId = payment.transactionId;

      // Step 2: Schedule shipment
      await context.activities.scheduleShipment({ orderId: args.orderId });

      return { success: true, transactionId: paymentTransactionId };
    } catch (error) {
      // Payment succeeded but shipment failed - can handle specially
      if (paymentTransactionId) {
        // Rollback payment
        await context.activities.refundPayment({ transactionId: paymentTransactionId });

        return {
          success: false,
          message: "Shipment failed, payment refunded",
          completedSteps: { payment: paymentTransactionId },
        };
      }

      return { success: false, message: "Payment failed" };
    }
  },
});

Combining results ​

unthrown exposes all([...]) to fan in a list of Results into a single Result<T[], E> that fails on the first error. Destructure the combined array, or call .match on the result:

typescript
import { all } from "unthrown";

const combined = all([validateA(a), validateB(b), validateC(c)]);

return combined.match({
  ok: ([resA, resB, resC]) => proceed({ resA, resB, resC }),
  err: (error) => fail(error),
  defect: (cause) => fail(cause),
});

Child Workflows ​

Child workflows return AsyncResult for consistent error handling:

Execute and Wait ​

typescript
import { declareWorkflow } from "@temporal-contract/worker/workflow";

export const parentWorkflow = declareWorkflow({
  workflowName: "parentWorkflow",
  contract: myContract,
  activityOptions: { startToCloseTimeout: "1 minute" },
  implementation: async (context, args) => {
    // Execute child workflow and wait for result
    const result = await context.executeChildWorkflow(myContract, "processPayment", {
      workflowId: `payment-${args.orderId}`,
      args: { amount: args.totalAmount },
    });

    // Workflows return plain objects, not Result
    return result.match({
      ok: (output) => ({
        success: true,
        transactionId: output.transactionId,
      }),
      err: (error) => ({
        success: false,
        error: error.message,
      }),
      defect: (cause) => ({
        success: false,
        error: cause instanceof Error ? cause.message : "Unexpected failure",
      }),
    });
  },
});

Start Without Waiting ​

typescript
export const parentWorkflow = declareWorkflow({
  workflowName: "parentWorkflow",
  contract: myContract,
  activityOptions: { startToCloseTimeout: "1 minute" },
  implementation: async (context, args) => {
    // Start child workflow without waiting
    const handleResult = await context.startChildWorkflow(myContract, "sendNotification", {
      workflowId: `notification-${args.orderId}`,
      args: { message: "Order received" },
    });

    handleResult.match({
      ok: async (handle) => {
        // Child started successfully
        // Can wait for result later if needed
        const result = await handle.result();
      },
      err: (error) => {
        console.error("Failed to start child:", error);
      },
      defect: (cause) => {
        console.error("Unexpected failure starting child:", cause);
      },
    });

    // Workflows return plain objects, not Result
    return { success: true };
  },
});

Cross-Contract Child Workflows ​

Invoke workflows from different contracts/workers:

typescript
import { orderContract, notificationContract } from "./contracts.js";

export const orderWorkflow = declareWorkflow({
  workflowName: "processOrder",
  contract: orderContract,
  activityOptions: { startToCloseTimeout: "1 minute" },
  implementation: async (context, args) => {
    // Child workflow from another contract
    const notifyResult = await context.executeChildWorkflow(
      notificationContract,
      "sendOrderConfirmation",
      {
        workflowId: `notify-${args.orderId}`,
        args: { orderId: args.orderId },
      },
    );

    // Workflows return plain objects, not Result
    return notifyResult.match({
      ok: () => ({ status: "completed" }),
      err: (error) => ({
        status: "failed",
        error: error.message,
      }),
      defect: (cause) => ({
        status: "failed",
        error: cause instanceof Error ? cause.message : "Unexpected failure",
      }),
    });
  },
});

The defect channel ​

unthrown models three outcomes, not two. Besides ok (success) and err (a deliberate, anticipated failure), there is a third channel — defect — for unanticipated failures: bugs, programmer errors, or any exception you never modeled.

  • An err is a value you returned on purpose (Err(...) / errAsync → Err(...).toAsync(), or a rejection mapped through fromPromise(promise, errFn)). It is part of your type signature.
  • A defect is captured when an unexpected throw escapes — for example a fromSafePromise(...) thunk that throws, or an unhandled exception inside a .map. It is not part of the modeled error type and carries the raw failure on result.cause.

A defect re-throws when you await/unwrap it rather than being handled as a value, so genuine bugs surface loudly instead of being silently swallowed. Inspect it with the free function isDefect(result) and result.cause, or handle all three channels at once with result.match({ ok, err, defect }):

typescript
import { isOk, isErr, isDefect } from "unthrown";

const result = await client.executeWorkflow("processOrder", {
  workflowId: "order-123",
  args: { orderId: "ORD-123", amount: 100 },
});

if (isOk(result)) {
  console.log(result.value);
} else if (isErr(result)) {
  console.error("Modeled failure:", result.error); // anticipated boundary error
} else if (isDefect(result)) {
  console.error("Unexpected failure (bug):", result.cause); // unmodeled
}

Keep deliberate boundary errors in the err channel (wrap them in ApplicationFailure for activities) and let only truly unexpected throws become defects.

TaggedError and matchTags ​

Error classes are built with TaggedError, which stamps each class with a _tag discriminant:

typescript
import { TaggedError } from "unthrown";

class PaymentDeclined extends TaggedError("PaymentDeclined")<{
  readonly customerId: string;
}> {}

class GatewayTimeout extends TaggedError("GatewayTimeout")<{
  readonly elapsedMs: number;
}> {}

NOTE

The worker's ValidationError subclasses are the exception — they still extend Temporal's ApplicationFailure rather than TaggedError.

NOTE

temporal-contract's own error classes namespace their tag with the package scope — e.g. _tag === "@temporal-contract/WorkflowExecutionNotFoundError" — so they never collide with a _tag from your own code or another library. Their .name stays the bare class name (e.g. "WorkflowExecutionNotFoundError") for readable logs. When folding library errors, the matchTags keys carry the prefix: matchTags(result, { "@temporal-contract/WorkflowExecutionNotFoundError": ... }).

Because every tagged error carries a _tag, unthrown's matchTags folds a Result exhaustively by tag, with dedicated Ok and Defect channels:

typescript
import { matchTags } from "unthrown";

const message = matchTags(result, {
  Ok: (value) => `charged ${value.transactionId}`,
  PaymentDeclined: (e) => `declined for ${e.customerId}`,
  GatewayTimeout: (e) => `timed out after ${e.elapsedMs}ms`,
  Defect: (cause) => `unexpected: ${String(cause)}`,
});

When to Use ​

Use Result / AsyncResult When: ​

  • In Activity Implementations: Always use AsyncResult for explicit error handling
  • For Child Workflows: Child workflows return Result for explicit error handling
  • For Type-Safe Errors: When you need ApplicationFailure with type / nonRetryable for proper retry policies

Use Standard async/await When: ​

  • In Workflow Logic: Use try/catch when calling activities from workflows
  • For Simple Error Handling: When standard exception handling is sufficient
  • For Deterministic Code: Workflows must remain deterministic

See Also ​

Released under the MIT License.