A Task<T, E> is a lazy async operation that doesn’t start until you call .execute(). Under the hood it always resolves to a ResultAsync<T, E>, giving you full access to the Result API after execution.
The key difference from a Promise: a Task is a description of work, not the work itself. You can attach retries, timeouts, and circuit breakers before a single network call has been made.
import { Task } from 'ts-chas/task';
// Define the work — nothing runs yet
const fetchTask = Task.from(
() => fetch('/api/data').then((r) => r.json()),
(e) => new Error(`Network error: ${e}`)
);
// Execute when ready
const result = await fetchTask.execute(); // Result<unknown, Error>
Creating tasks
Task.from(fn, onError)
Wraps a function that returns a Promise. The optional onError mapper converts rejected values into typed errors:
const fetchUser = Task.from(
() => fetch('/api/user').then((r) => r.json()) as Promise<User>,
(e) => new Error(`Failed: ${e}`)
);
// Task<User, Error>
Task.ask<Context>()
Creates a task that returns the context provided via .provide(). Use this for dependency injection:
interface AppContext {
apiUrl: string;
authToken: string;
}
const fetchUsers = Task.ask<AppContext>().chain((ctx) =>
Task.from(
() =>
fetch(`${ctx.apiUrl}/users`, {
headers: { Authorization: ctx.authToken },
}).then((r) => r.json()),
(e) => new Error(`Fetch failed: ${e}`)
)
);
// Inject context at the call site
const result = await fetchUsers
.provide({ apiUrl: 'https://api.example.com', authToken: 'Bearer abc' })
.execute();
Chaining
.chain(fn)
Chains a function that returns another Task. Errors short-circuit automatically:
const pipeline = Task.from(() => fetchRawData())
.chain((data) => Task.from(() => processData(data)))
.chain((processed) => Task.from(() => saveResult(processed)));
.map(fn) and .mapErr(fn)
Transform the value or error without creating a new async operation:
const userNameTask = Task.from(() => fetchUser())
.map((user) => user.name)
.mapErr((e) => `Could not get name: ${e.message}`);
Resilience
Chain resilience operators before calling .execute(). They compose cleanly and are applied in order:
const resilientFetch = Task.from(
() => fetch('/api/data').then((r) => r.json()),
(e) => new Error(`${e}`)
)
.retry(3, { delay: 1000, factor: 2 }) // up to 3 retries, exponential backoff
.timeout(5000, () => new Error('Timed out')) // fail fast after 5 seconds
.circuitBreaker({ threshold: 5, resetTimeout: 30_000 }); // open after 5 failures
.retry(count, options)
Retries the task up to count times on failure. Pass delay (ms) and factor for exponential backoff:
task.retry(3, { delay: 500, factor: 2 });
// Waits: 500ms → 1000ms → 2000ms between attempts
.timeout(ms, onTimeout)
Fails with a custom error if the task takes longer than ms milliseconds:
task.timeout(3000, () => new Error('Request timed out'));
.circuitBreaker({ threshold, resetTimeout })
Opens the circuit after threshold consecutive failures and rejects all calls until resetTimeout ms have elapsed:
task.circuitBreaker({ threshold: 5, resetTimeout: 60_000 });
.throttle(concurrency)
Limits the number of concurrent executions of this task:
const uploadTask = Task.from(() => uploadFile(file)).throttle(3);
// At most 3 uploads run at once
.fallback(otherTask)
Runs otherTask if the primary task fails:
const primaryTask = Task.from(() => fetch('https://primary.example.com/data'));
const backupTask = Task.from(() => fetch('https://backup.example.com/data'));
const result = await primaryTask.fallback(backupTask).execute();
Recovery
.orElse(fn)
Recover from any error by returning a new Task:
const result = await fetchFromRemote()
.orElse(() => Task.from(() => loadFromDisk()))
.execute();
.catchTag(errorFactory, handler)
Catch a specific tagged error variant. It is removed from the error union type after handling:
import { chas } from 'ts-chas';
const AppError = chas.defineErrs({
NotFound: (resource: string) => ({ resource }),
Unauthorized: () => ({}),
});
const result = await fetchUser(id)
.catchTag(AppError.NotFound, (e) =>
Task.from(() => Promise.resolve(GUEST_USER))
)
.execute();
// Task<User, Unauthorized> — NotFound is no longer in the error type
Side effects
const result = await fetchUser(id)
.tap((user) => console.log('Got user:', user.name)) // runs on success
.tapErr((e) => console.error('Failed:', e.message)) // runs on failure
.execute();
Execution control
.delay(ms)
Waits ms milliseconds before executing:
task.delay(500).execute(); // waits 500ms then runs
.withSignal(abortSignal)
Cancels the task if the signal is aborted:
const controller = new AbortController();
const result = await task.withSignal(controller.signal).execute();
// Elsewhere: controller.abort();
.once()
Executes only once and caches the result for all subsequent calls:
const initTask = Task.from(() => initializeApp()).once();
await initTask.execute(); // runs the init
await initTask.execute(); // returns cached result immediately
.memoize({ ttl? })
Like .once(), but supports a time-to-live in milliseconds after which the cache expires:
const userTask = Task.from(() => fetchUser(id))
.memoize({ ttl: 60_000 }); // cached for 1 minute
.cache(key, store)
Uses a custom cache store implementing the TaskCache interface. Useful for Redis, IndexedDB, or any external store:
import type { TaskCache } from 'ts-chas/task';
const redisCache: TaskCache = {
get: async (key) => redis.get(key).then((v) => (v ? JSON.parse(v) : undefined)),
set: async (key, value, ttl) =>
redis.set(key, JSON.stringify(value), 'PX', ttl ?? 60_000),
};
const result = await task.cache('user:123', redisCache, { ttl: 30_000 }).execute();
Context/DI with Task.ask()
Task.ask() combined with .provide() is a lightweight dependency injection pattern that keeps your tasks testable and decoupled:
interface DbContext {
dbUrl: string;
}
const queryTask = Task.ask<DbContext>().chain((ctx) =>
Task.from(
() => db.connect(ctx.dbUrl).then((conn) => conn.query('SELECT * FROM users')),
(e) => new Error(`DB error: ${e}`)
)
);
// In production
const result = await queryTask
.provide({ dbUrl: process.env.DATABASE_URL! })
.execute();
// In tests
const testResult = await queryTask
.provide({ dbUrl: 'postgresql://localhost/test_db' })
.execute();
Resource management
Task.using() acquires a resource, runs a task, and guarantees the resource is released, even if the task fails:
const result = await Task.using(
Task.from(() => db.getConnection(), (e) => new Error(`${e}`)), // acquire
(conn) => conn.release(), // release
(conn) =>
Task.from(
() => conn.query('SELECT * FROM users'),
(e) => new Error(`Query failed: ${e}`)
)
).execute();
Parallel execution
const tasks = [fetchUser('1'), fetchUser('2'), fetchUser('3')];
// All must succeed (short-circuits on first Err)
const allResult = await Task.all(tasks).execute();
// Result<User[], Error>
// Succeeds with the first Ok, or Err with all errors
const anyResult = await Task.any(tasks).execute();
// Result<User, Error[]>
// Collects all errors instead of short-circuiting
const collectResult = await Task.collect(tasks).execute();
// Result<User[], Error[]>
// Resolves with whichever completes first
const raceResult = await Task.race(tasks).execute();
// Result<User, Error>
// Run with a concurrency limit
const parallelResult = await Task.parallel(tasks, 2).execute();
// At most 2 tasks run at once
Do-notation with Task.go()
Use Task.go() to write sequential task pipelines that look imperative, without nesting .chain() calls:
const userProfileTask = Task.go(async function* () {
const user = yield* fetchUserTask(id); // short-circuits on Err
const posts = yield* fetchPostsTask(user.id);
const friends = yield* fetchFriendsTask(user.id);
return { user, posts, friends };
});
const result = await userProfileTask.execute();
// Result<{ user: User; posts: Post[]; friends: User[] }, ApiError>
Task.go() is the Task equivalent of chas.go() for Results. Both use JavaScript generators and the yield* operator as a typed short-circuit mechanism.