API reference / @evolu/common / Task / Task

Interface: Task()<T, E>

Defined in: packages/common/src/Task.ts:152

Task is a lazy, cancellable Promise that returns Result instead of throwing.

In other words, Task is a function that creates a Promise when it's called. This laziness allows safe composition, e.g. retry logic because it prevents eager execution.

Cancellation

Tasks support optional cancellation via signal in TaskContext. When a Task is called without a signal, it cannot be cancelled and AbortError will never be returned. When called with a signal, the Task can be cancelled and AbortError is added to the error union with precise type safety.

When composing Tasks, we typically have context and want to abort ASAP by passing it through. However, there are valid cases where we don't want to abort because we need some atomic unit to complete. For simple scripts and tests, omitting context is fine.

Task Helpers

  • toTask - Convert async function to Task
  • wait - Delay execution for a specified Duration
  • timeout - Add timeout to any Task
  • retry - Retry failed Tasks with configurable backoff

Example

interface FetchError {
  readonly type: "FetchError";
  readonly error: unknown;
}

// Task version of fetch with proper error handling and cancellation support.
const fetch = (url: string) =>
  toTask((context) =>
    tryAsync(
      () => globalThis.fetch(url, { signal: context?.signal ?? null }),
      (error): FetchError => ({ type: "FetchError", error }),
    ),
  );

// `satisfies` shows the expected type signature.
fetch satisfies (url: string) => Task<Response, FetchError>;

// Add timeout to prevent hanging
const fetchWithTimeout = (url: string) => timeout("30s", fetch(url));

fetchWithTimeout satisfies (
  url: string,
) => Task<Response, TimeoutError | FetchError>;

// Add retry for resilience
const fetchWithRetry = (url: string) =>
  retry(
    {
      retries: PositiveInt.orThrow(3),
      initialDelay: "100ms",
    },
    fetchWithTimeout(url),
  );

fetchWithRetry satisfies (
  url: string,
) => Task<
  Response,
  TimeoutError | FetchError | RetryError<TimeoutError | FetchError>
>;

const semaphore = createSemaphore(PositiveInt.orThrow(2));

// Control concurrency with semaphore
const fetchWithPermit = (url: string) =>
  semaphore.withPermit(fetchWithRetry(url));

fetchWithPermit satisfies (url: string) => Task<
  Response,
  | TimeoutError
  | FetchError
  | AbortError // Semaphore dispose aborts Tasks
  | RetryError<TimeoutError | FetchError>
>;

// Usage
const results = await Promise.all(
  [
    "https://api.example.com/users",
    "https://api.example.com/posts",
    "https://api.example.com/comments",
  ]
    .map(fetchWithPermit)
    .map((task) => task()),
);

results satisfies Array<
  Result<
    Response,
    | AbortError
    | TimeoutError
    | FetchError
    | RetryError<TimeoutError | FetchError>
  >
>;

// Handle results
for (const result of results) {
  if (result.ok) {
    // Process successful response
    const response = result.value;
    expect(response).toBeInstanceOf(Response);
  } else {
    // Handle error (TimeoutError, FetchError, RetryError, or AbortError)
    expect(result.error).toBeDefined();
  }
}

// Cancellation support
const controller = new AbortController();
const cancelableTask = fetchWithPermit("https://api.example.com/data");

// Start task
const promise = cancelableTask(controller);

// Cancel after some time
setTimeout(() => {
  controller.abort("User cancelled");
}, 1000);

const _result = await promise;
// Result will be AbortError if cancelled

Dependency Injection Integration

Tasks integrate naturally with Evolu's DI pattern. Use deps for static dependencies and TaskContext for execution context like cancellation. Usage follows the pattern: deps → arguments → execution context.

Type Parameters

Type Parameter
T
E
Task<TContext>(context?): Promise<Result<T, TContext extends object ?
  | E
| AbortError : E>>;

Defined in: packages/common/src/Task.ts:194

Invoke the Task.

Provide a context with an AbortSignal to enable cancellation. When called without a signal, AbortError cannot occur and the error type narrows accordingly.

Example

interface FetchError {
  readonly type: "FetchError";
  readonly error: unknown;
}

// Task version of fetch with proper error handling and cancellation support.
const fetch = (url: string) =>
  toTask((context) =>
    tryAsync(
      () => globalThis.fetch(url, { signal: context?.signal ?? null }),
      (error): FetchError => ({ type: "FetchError", error }),
    ),
  );

// `satisfies` shows the expected type signature.
fetch satisfies (url: string) => Task<Response, FetchError>;

const result1 = await fetch("https://api.example.com/data")();
expectTypeOf(result1).toEqualTypeOf<Result<Response, FetchError>>();

// With AbortController
const controller = new AbortController();
const result2 = await fetch("https://api.example.com/data")(controller);
expectTypeOf(result2).toEqualTypeOf<
  Result<Response, FetchError | AbortError>
>();

Type Parameters

Type ParameterDefault type
TContext extends | TaskContext | undefinedundefined

Parameters

ParameterType
context?TContext

Returns

Promise<Result<T, TContext extends object ? | E | AbortError : E>>

Was this page helpful?