Skip to content

What TypeScript Gives You Back

This is the page to read if you only read one. awaitly’s value is not that it avoids try/catch — it’s that the compiler knows every way your code can fail, without you writing that list down. This page states exactly what you get and why.

Everything is one of these:

type Ok<T> = { ok: true; value: T };
type Err<E, C> = { ok: false; error: E; cause?: C };
type Result<T, E, C = unknown> = Ok<T> | Err<E, C>;
type AsyncResult<T, E, C = unknown> = Promise<Result<T, E, C>>;

AsyncResult<T, E> is just Promise<Result<T, E>>. Use it as the return type of any async operation that can fail.

ok is a discriminant, so a single if narrows both branches:

const result = await getUser('1');
if (result.ok) {
result.value; // User
result.error; // compile error — doesn't exist on this branch
} else {
result.error; // 'NOT_FOUND'
result.value; // compile error
}

Rule 1: the error union comes from your deps

Section titled “Rule 1: the error union comes from your deps”

You never write the error type of a run() or a workflow. It is computed:

const getUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> => { /* ... */ };
const getPosts = async (id: string): AsyncResult<Post[], 'FETCH_ERROR'> => { /* ... */ };
const notify = async (to: string): AsyncResult<void, 'EMAIL_FAILED'> => { /* ... */ };
const result = await run({ getUser, getPosts, notify }, async (s) => { /* ... */ });
// ^? Result<T, 'NOT_FOUND' | 'FETCH_ERROR' | 'EMAIL_FAILED' | UnexpectedError>

Each dep contributes its own E. The union is their sum. Add a dep and it widens; remove one and it narrows — and every switch over result.error is re-checked.

This holds identically for createWorkflow:

const wf = createWorkflow('loadUser', { getUser, getPosts });
const result = await wf.run(async ({ steps }) => { /* ... */ });
// ^? Result<T, 'NOT_FOUND' | 'FETCH_ERROR' | UnexpectedError>

You see the literal union on hover, not an opaque alias like ErrorsOf<Deps>. That’s deliberate, so a typo like result.error === 'NOT_FUOND' is a compile error.

Rule 2: inside the callback, values are unwrapped

Section titled “Rule 2: inside the callback, values are unwrapped”

The deps object you pass in comes back with the same keys and the same arguments, but the Result is stripped off:

// declared: (id: string) => AsyncResult<User, 'NOT_FOUND'>
// bound as: (id: string) => Promise<User>
await run({ getUser }, async (s) => {
const user = await s.getUser('1');
// ^? User — not Result<User, 'NOT_FOUND'>
});

The 'NOT_FOUND' didn’t vanish — it moved to the outer result type. That is the trade the whole library makes: no error handling in the middle, all of it at the edge.

Rule 3: UnexpectedError is always there (unless you remove it)

Section titled “Rule 3: UnexpectedError is always there (unless you remove it)”

A dep that returns err('X') contributes 'X'. A dep that throws contributes UnexpectedError, because a throw is not in any signature:

const result = await run({ getUser }, async (s) => { /* ... */ });
// ^? Result<T, 'NOT_FOUND' | UnexpectedError>
if (!result.ok && isUnexpectedError(result.error)) {
result.error.cause; // unknown — the original thrown value
}

To get a closed union with no UnexpectedError, map throws to your own type with catchUnexpected. Note this is not available on the deps-first run(deps, fn) form — it lives on createWorkflow (at creation) and on run.strict:

// On a workflow — deps still infer as normal
const wf = createWorkflow('loadUser', { getUser }, {
catchUnexpected: (thrown) => ({ type: 'CRASHED' as const, thrown }),
});
// result.error: 'NOT_FOUND' | { type: 'CRASHED'; thrown: unknown }
// On a bare run, via run.strict
type Crash = { type: 'CRASHED'; thrown: unknown };
const result = await run.strict<User, 'NOT_FOUND' | Crash>(
async ({ step }) => step('getUser', () => getUser('1')),
{ catchUnexpected: (thrown) => ({ type: 'CRASHED', thrown }) }
);
// ^? Result<User, 'NOT_FOUND' | Crash>

With run.strict you supply E yourself, and it must cover both your step errors and whatever catchUnexpected returns — it is the one place awaitly stops inferring the union for you. That is the cost of closing it.

A dep that doesn’t return a Result still works. Its value passes through and it contributes nothing to the error union — only UnexpectedError if it throws:

const slugify = (s: string) => s.toLowerCase(); // no Result
const getUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> => { /* ... */ };
const result = await run({ getUser, slugify }, async (s) => {
const user = await s.getUser('1');
return s.slugify(user.name);
});
// ^? Result<string, 'NOT_FOUND' | UnexpectedError>

This is what makes incremental adoption work: wrap existing functions as-is, then convert them to return Results one at a time and watch the union fill in.

When you need the error type by name — for an HTTP mapper, a test helper, a shared handler — derive it rather than retyping it:

import { type ErrorsOf, type ErrorOf } from 'awaitly';
const deps = { getUser, getPosts, notify };
type AppError = ErrorsOf<typeof deps>;
// ^? 'NOT_FOUND' | 'FETCH_ERROR' | 'EMAIL_FAILED'
type UserError = ErrorOf<typeof getUser>;
// ^? 'NOT_FOUND'

Note ErrorsOf gives you the declared errors only. The runtime union adds UnexpectedError on top; use ErrorsOf<typeof deps> | UnexpectedError if you’re annotating a handler for a non-strict run.

Because the union is literal, a switch can be made to fail the build when a new error appears:

function toResponse(error: AppError) {
switch (error) {
case 'NOT_FOUND': return { status: 404 };
case 'FETCH_ERROR': return { status: 502 };
case 'EMAIL_FAILED': return { status: 500 };
default: {
const _exhaustive: never = error; // adding a dep breaks compilation here
return _exhaustive;
}
}
}

Add notifySlack to deps and this function stops compiling until you handle its error. That is the guarantee — not that errors are typed, but that forgetting one is a build failure.

You write You get back
AsyncResult<User, 'NOT_FOUND'> Promise<{ok:true,value:User} | {ok:false,error:'NOT_FOUND'}>
run({ a, b }, fn) Result<T, ErrorOf<a> | ErrorOf<b> | UnexpectedError>
createWorkflow('n', { a, b }) then .run(fn) same union as above
run.strict(fn, { catchUnexpected }) Result<T, E> — closed, no UnexpectedError
s.getUser(id) inside a callback Promise<User> — unwrapped
a plain (non-Result) dep value passes through, adds nothing to the union
a dep that throws UnexpectedError, original in .cause

Handling errors at the boundary →