Skip to content

Result Pattern

Learn how to use explicit error handling with the Result/Future pattern.

Overview

temporal-contract uses the Result/Future pattern for explicit error handling. The implementation differs between activities and workflows:

  • Activities and Clients: Use @swan-io/boxed - a battle-tested library with excellent performance
  • Workflows: Use @temporal-contract/boxed - a Temporal-compatible implementation required for deterministic execution

Both packages provide the same API, making it easy to work with both.

Installation

bash
# For activities and clients
pnpm add @swan-io/boxed

# For workflows (Temporal-compatible)
pnpm add @temporal-contract/boxed

Basic Usage

Activities (using @swan-io/boxed)

Activities use @swan-io/boxed for excellent performance and ecosystem compatibility:

typescript
import { declareActivitiesHandler, ActivityError } from "@temporal-contract/worker/activity";
import { Future, Result } from "@swan-io/boxed";
import { orderContract } from "./contract";

export const activities = declareActivitiesHandler({
  contract: orderContract,
  activities: {
    processPayment: ({ amount }) => {
      return Future.fromPromise(paymentGateway.charge(amount))
        .mapError(
          (error) =>
            new ActivityError(
              "PAYMENT_FAILED",
              error instanceof Error ? error.message : "Payment failed",
              error,
            ),
        )
        .mapOk((txId) => ({ transactionId: txId, success: true }));
    },

    sendEmail: ({ to, body }) => {
      return Future.fromPromise(emailService.send({ to, body }))
        .mapError(
          (error) =>
            new ActivityError(
              "EMAIL_FAILED",
              error instanceof Error ? error.message : "Email failed",
              error,
            ),
        )
        .mapOk(() => ({ sent: true }));
    },
  },
});

Workflows (using @temporal-contract/boxed)

Workflows require @temporal-contract/boxed for Temporal's deterministic execution:

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

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

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

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

Clients (using @swan-io/boxed)

Clients use @swan-io/boxed to handle workflow results:

typescript
import { TypedClient } from "@temporal-contract/client";
import { Result } from "@swan-io/boxed";
import { Client } from "@temporalio/client";
import { orderContract } from "./contract";

const temporalClient = new Client({ connection });

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

// Handle result with pattern matching
result.match({
  Ok: (value) => {
    console.log("Order processed:", value.transactionId);
  },
  Error: (error) => {
    console.error("Order failed:", error);
  },
});

Why Two Packages?

@swan-io/boxed (Activities & Clients)

  • ✅ Battle-tested with extensive usage
  • ✅ Excellent performance optimizations
  • ✅ Large ecosystem support
  • ✅ Works perfectly outside of Temporal workflows

@temporal-contract/boxed (Workflows)

  • ✅ Temporal deterministic execution compatible
  • ✅ Same API as @swan-io/boxed
  • ✅ Designed specifically for workflow constraints
  • ✅ Seamless interoperability with @swan-io/boxed

Interoperability

Both packages share the same API surface, so code is portable:

typescript
// Same API for both packages!
const result = Result.Ok(42);
const future = Future.value(42);

result.match({
  Ok: (value) => console.log(value),
  Error: (error) => console.error(error),
});

For explicit conversion between the two (rarely needed), see the @temporal-contract/boxed interop documentation.

Pattern Matching

Activities return plain values when called from workflows. If an activity fails, it will throw an error:

typescript
export const processOrder = declareWorkflow({
  workflowName: "processOrder",
  contract: orderContract,
  implementation: async ({ activities }, input) => {
    try {
      // Activity returns plain value (Result is unwrapped internally)
      const payment = await 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,
  implementation: async ({ activities }, input) => {
    try {
      // Activities return plain values
      const payment = await activities.processPayment({ amount: 100 });

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

      // Update database
      await 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
type PaymentError =
  | { type: "InsufficientFunds" }
  | { type: "CardDeclined" }
  | { type: "NetworkError"; message: string };

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

// Activities return Future with typed errors
processPayment: ({ amount }) => {
  return Future.fromPromise(paymentGateway.charge(amount))
    .mapError<ActivityError>((error) => {
      // Wrap domain errors in ActivityError for Temporal retry policies
      return new ActivityError(
        "PAYMENT_FAILED",
        error instanceof Error ? error.message : "Payment failed",
        error,
      );
    })
    .mapOk((txId) => ({ transactionId: txId }));
};

Benefits

1. Explicit Error Handling

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

typescript
// Activity implementation (uses Result pattern)
const processPayment = ({ amount }) => {
  return Future.fromPromise(paymentGateway.charge(amount))
    .mapError((error) => new ActivityError("PAYMENT_FAILED", "Payment failed", error))
    .mapOk((txId) => ({ transactionId: txId }));
};

// Workflow (uses standard try/catch for activities)
export const processOrder = declareWorkflow({
  workflowName: "processOrder",
  contract: myContract,
  implementation: async ({ activities }, input) => {
    try {
      // Activity returns plain value
      const payment = await 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 Results instead of throwing:

typescript
// ✅ Clear - activity returns Future<Result>
const processPayment = ({ amount }) => {
  return Future.fromPromise(paymentGateway.charge(amount))
    .mapError((error) => new ActivityError("PAYMENT_FAILED", "Payment failed", error))
    .mapOk((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:

typescript
// Activity implementation with chaining
const processOrder = ({ orderId }) => {
  return validateOrderId(orderId)
    .flatMap((validId) => fetchOrder(validId))
    .flatMap((order) => processPayment(order))
    .flatMap((payment) => updateDatabase(payment))
    .mapError((error) => new ActivityError("ORDER_FAILED", "Order processing failed", error));
  // 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,
  implementation: async ({ activities }, input) => {
    let paymentTransactionId: string | undefined;

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

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

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

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

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

Child Workflows

Child workflows use the same Future/Result pattern for consistent error handling:

Execute and Wait

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

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

    return result.match({
      Ok: (output) =>
        Result.Ok({
          success: true,
          transactionId: output.transactionId,
        }),
      Error: (error) =>
        Result.Error({
          type: "ChildWorkflowFailed",
          error,
        }),
    });
  },
});

Start Without Waiting

typescript
export const parentWorkflow = declareWorkflow({
  workflowName: "parentWorkflow",
  contract: myContract,
  implementation: async ({ startChildWorkflow }, input) => {
    // Start child workflow without waiting
    const handleResult = await startChildWorkflow(myContract, "sendNotification", {
      workflowId: `notification-${input.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();
      },
      Error: (error) => {
        console.error("Failed to start child:", error);
      },
    });

    return Result.Ok({ success: true });
  },
});

Cross-Contract Child Workflows

Invoke workflows from different contracts/workers:

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

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

    return notifyResult.match({
      Ok: () => Result.Ok({ status: "completed" }),
      Error: (error) =>
        Result.Error({
          type: "NotificationFailed",
          error,
        }),
    });
  },
});

When to Use

Use Result/Future Pattern When:

  • In Activity Implementations: Always use Future/Result pattern for explicit error handling
  • For Child Workflows: Child workflows return Results for explicit error handling
  • For Type-Safe Errors: When you need ActivityError with 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.