Skip to main content
Task<T, E> is a lazy, composable wrapper for async operations. Unlike a Promise, a Task does not start executing until you call .execute(). Every Task resolves to a Result<T, E>, giving you explicit, type-safe error handling throughout the chain.
import { Task } from 'ts-chas/task';

Static factory methods

Task.from(fn, onError?)

Creates a Task from a function that returns a Promise. The optional onError mapper converts any thrown error into your typed E.
const fetchUser = Task.from(
  () => fetch('/api/user/1').then(r => r.json()),
  (e) => new Error(`Fetch failed: ${e}`)
);

const result = await fetchUser.execute();
// Result<User, Error>
If you omit onError, the error type is unknown.

Task.ask<C>()

Creates a Task that resolves to the context value injected via .provide(). Use this to declare context dependencies without passing them explicitly through every function.
interface AppContext {
  dbUrl: string;
}

const dbTask = Task.ask<AppContext>().chain(ctx =>
  Task.from(
    () => fetch(`${ctx.dbUrl}/records`).then(r => r.json()),
    () => new Error('DB fetch failed')
  )
);

const result = await dbTask.provide({ dbUrl: 'https://db.example.com' }).execute();

Task.go(generatorFn)

Do-notation for Tasks. Lets you write sequential async logic that looks imperative, while still short-circuiting on the first Err.
const task = Task.go(async function* () {
  const user = yield* Task.from(
    () => fetch('/api/user').then(r => r.json()),
    () => new Error('fetch failed')
  );

  const profile = yield* Task.from(
    () => fetch(`/api/profile/${user.id}`).then(r => r.json()),
    () => new Error('profile fetch failed')
  );

  return { user, profile };
});

const result = await task.execute();
// Result<{ user: User; profile: Profile }, Error>

Task.void()

Returns a Task that resolves to Ok(undefined). Useful as a no-op or placeholder.
const noop = Task.void();
const result = await noop.execute(); // Ok(undefined)

Task.fromOption(option, onNone)

Converts an Option<T> into a Task<T, E>. If the option is None, the task fails with the value returned by onNone.
import { nullable } from 'ts-chas/option';

const cached = nullable(localStorage.getItem('token'));

const task = Task.fromOption(cached, () => new Error('No cached token'));
const result = await task.execute();
// Result<string, Error>

Task.all(tasks)

Runs all tasks. Resolves to Ok with an array of all values if every task succeeds. Short-circuits to the first Err encountered.
const tasks = [
  Task.from(() => Promise.resolve(1)),
  Task.from(() => Promise.resolve(2)),
  Task.from(() => Promise.resolve(3)),
];

const result = await Task.all(tasks).execute();
// Ok([1, 2, 3]) or Err(firstError)

Task.race(tasks)

Returns the result of whichever task settles first — success or failure.
const result = await Task.race([
  Task.from(() => fetch('/api/primary').then(r => r.json())),
  Task.from(() => fetch('/api/mirror').then(r => r.json())),
]).execute();

Task.any(tasks)

Returns the first Ok. Only resolves to Err if all tasks fail, in which case the error is an array of all errors.
const result = await Task.any([
  Task.from(() => fetchFromPrimary()),
  Task.from(() => fetchFromReplica()),
]).execute();
// Ok(firstSuccessfulValue) or Err([err1, err2])

Task.collect(tasks)

Runs all tasks without short-circuiting. Resolves to Ok with all values if all succeed, or Err with all errors if any fail.
const result = await Task.collect([
  Task.from(() => validateEmail(input)),
  Task.from(() => validateAge(input)),
]).execute();
// Ok([...]) or Err([emailErr, ageErr])

Task.parallel(tasks, concurrency)

Runs tasks with a bounded concurrency limit. Returns Ok with all values if all succeed, or the first Err.
const urls = ['/api/1', '/api/2', '/api/3', '/api/4', '/api/5'];

const tasks = urls.map(url =>
  Task.from(() => fetch(url).then(r => r.json()))
);

// Run at most 2 fetches at a time
const result = await Task.parallel(tasks, 2).execute();
// Ok([data1, data2, ...]) or Err(firstError)

Task.using(acquire, release, use)

Resource management helper. Acquires a resource, passes it to use, then calls release — even if use fails.
const result = await Task.using(
  Task.from(() => openDatabaseConnection(), () => new Error('connect failed')),
  (conn) => conn.close(),
  (conn) => Task.from(() => conn.query('SELECT * FROM users'), () => new Error('query failed'))
).execute();

Instance methods

.chain(fn)

flatMap for Tasks. fn receives the Ok value and returns a new Task. Short-circuits on Err.
const result = await Task.from(() => Promise.resolve(1))
  .chain(n => Task.from(() => Promise.resolve(n + 10)))
  .execute();
// Ok(11)

.map(fn)

Transforms the Ok value without changing the error type.
const result = await Task.from(() => Promise.resolve('hello'))
  .map(s => s.toUpperCase())
  .execute();
// Ok('HELLO')

.mapErr(fn)

Transforms the Err value without changing the success type.
const result = await Task.from(
  () => Promise.reject('oops'),
  (e) => String(e)
)
  .mapErr(msg => new Error(msg))
  .execute();
// Err(Error('oops'))

.tap(fn)

Runs a side effect on the Ok value. The result passes through unchanged.
const result = await Task.from(() => fetchUser())
  .tap(user => console.log('Fetched:', user.name))
  .execute();

.tapErr(fn)

Runs a side effect on the Err value. The result passes through unchanged.
const result = await Task.from(() => fetchUser())
  .tapErr(err => logger.error('Fetch failed', err))
  .execute();

.tapTag(target, handler)

Runs a side effect when the error matches a specific tagged error, without altering the result. Accepts either an error factory from defineErrs or a tag string.
const result = await fetchUser()
  .tapTag(AppError.NotFound, e => {
    metrics.increment('user.not_found');
  })
  .execute();

.orElse(fn)

Recovery operator. If the Task fails, fn receives the error and returns a new Task to run instead.
const result = await fetchUserFromApi()
  .orElse(err => fetchUserFromCache())
  .execute();

.fallback(otherTask)

Semantic alias for .orElse(() => otherTask). Use when you always want to fall back to a fixed Task regardless of the error.
const result = await fetchUserFromApi()
  .fallback(Task.from(() => getCachedUser()))
  .execute();

.catchTag(target, handler)

Catches a specific tagged error, handles it, and removes it from the error union type. The remaining union type is narrowed automatically.
// fetchUser returns Task<User, NotFoundErr | UnauthorizedErr>
const result = await fetchUser()
  .catchTag(AppError.NotFound, e =>
    Task.from(() => Promise.resolve(defaultUser))
  )
  .execute();
// Task<User, UnauthorizedErr>

.provide(context)

Injects a context value for Task.ask() chains. Call this before .execute().
const task = Task.ask<{ baseUrl: string }>().chain(ctx =>
  Task.from(() => fetch(ctx.baseUrl + '/data').then(r => r.json()))
);

const result = await task.provide({ baseUrl: 'https://api.example.com' }).execute();

Execution

.execute(signal?)

Runs the task and returns Promise<Result<T, E>>. Accepts an optional AbortSignal to cancel execution.
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);

const result = await task.execute(controller.signal);

if (result.isOk()) {
  console.log(result.value);
} else {
  console.error(result.error);
}

.unwrap()

Runs the task and returns Promise<T>. Throws if the result is Err. Use only when you’re certain the Task cannot fail.
const value = await Task.from(() => Promise.resolve(42)).unwrap();
// 42

Resilience

These operators let you build fault-tolerant tasks. See Resilience Patterns for full details and examples.
MethodDescription
.retry(count, options?)Retry on failure, with optional exponential backoff
.timeout(ms, onTimeout)Fail if the task takes longer than ms milliseconds
.circuitBreaker(options)Open the circuit after too many consecutive failures
.throttle(n)Limit concurrent executions to n
.delay(ms)Wait ms milliseconds before executing
.withSignal(signal)Bind an AbortSignal; fail immediately if already aborted

Caching

.once()

Executes the task once and caches the result forever. Subsequent calls return the cached Result without re-running.
const initTask = Task.from(() => loadConfig()).once();

// Both calls resolve to the same Result; the fetch only happens once
await initTask.execute();
await initTask.execute();

.memoize(options?)

In-memory memoization with an optional TTL. By default, only successful results are cached.
const userTask = Task.from(() => fetchUser()).memoize({ ttl: 60_000 });

await userTask.execute(); // fetches
await userTask.execute(); // returns cached result (within 60s)
OptionTypeDescription
ttlnumberCache lifetime in milliseconds. If omitted, caches forever.
cacheErrbooleanIf true, errors are also cached. Default: false.

.cache(key, store, options?)

External cache via any object that implements TaskCache. Useful for Redis, local storage, or shared application state.
const store = new Map<string, any>();

const result = await Task.from(() => fetchUser())
  .cache('user:1', store, { ttl: 30_000 })
  .execute();

Context

.context(ctx)

Attaches debug information to the error if the Task resolves to Err. The _context array on the error shows the most recent annotation first. Useful for tracing which step in a chain failed.
const result = await Task.from(() => fetchUser())
  .context('fetching user for dashboard')
  .execute();

if (result.isErr()) {
  console.log(result.error._context); // ['fetching user for dashboard']
}
Accepts either a string or a plain object.
.context({ step: 'fetch-user', userId: '123' })

TaskCache interface

Implement this interface to use any storage backend with .cache().
interface TaskCache {
  get<T>(key: string): T | undefined | Promise<T | undefined>;
  set<T>(key: string, value: T, ttl?: number): void | Promise<void>;
}
A plain Map satisfies this interface. For external stores, implement both methods:
const redisStore: TaskCache = {
  get: async (key) => {
    const raw = await redis.get(key);
    return raw ? JSON.parse(raw) : undefined;
  },
  set: async (key, value, ttl) => {
    const serialized = JSON.stringify(value);
    if (ttl) {
      await redis.set(key, serialized, 'PX', ttl);
    } else {
      await redis.set(key, serialized);
    }
  },
};

const result = await Task.from(() => fetchUser())
  .cache('user:1', redisStore, { ttl: 60_000 })
  .execute();