Algebraic Thinking Without the Ceremony
15 Apr 2026Christian Ekrem's post, Effect Without Effect-TS: Algebraic Thinking in Plain TypeScript, shows something useful: typed errors, explicit dependencies, and composition do not require a framework.
This post takes the same principles and shows where awaitly helps once the manual approach starts to repeat. The point is not "no ceremony."
The point is where ceremony lives.
For most code, run() is the starting point. createWorkflow() is for orchestration features like retries, caching, observability, or managed dependency wiring.
Raw async/await Manual Result pattern awaitly
────────────────── ────────────────────── ──────────────────────
Least setup Most boilerplate Moderate setup
Least honesty Most explicit control Lower repeated boilerplate
Errors invisible Errors visible (expected) Stronger guarantees
Unexpected errors: 🤷 Unexpected errors: typed
The function that lies to you
async function signupUser(email: string, password: string): Promise<User> {
if (!isValidEmail(email)) throw new Error('Invalid email');
const existing = await db.findUserByEmail(email);
if (existing) throw new Error('Email already registered');
const user = await db.createUser({
email,
passwordHash: await hash(password),
});
await emailService.sendWelcome(user.email);
await analytics.track('user_signed_up', { userId: user.id });
return user;
}
Promise<User> looks honest, but it is not. The function can fail in several ways and depends on external systems. None of that is visible in the signature.
Idea 1: Honest errors
Manual Result style works:
type SignupError =
| { _tag: 'InvalidEmail' }
| { _tag: 'EmailTaken'; email: string }
| { _tag: 'DbError'; cause: unknown }
| { _tag: 'EmailServiceDown' };
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
This is good engineering. You can share helpers and keep it clean. The maintenance cost appears when each module owns its own error union and every step change modifies that union.
Awaitly equivalent
import { ok, err, type AsyncResult } from 'awaitly';
const validateEmail = async (
email: string,
): AsyncResult<string, 'INVALID_EMAIL'> =>
isValidEmail(email) ? ok(email) : err('INVALID_EMAIL');
const checkDuplicate = async (
email: string,
): AsyncResult<void, 'EMAIL_TAKEN'> => {
const existing = await db.findUserByEmail(email);
return existing ? err('EMAIL_TAKEN') : ok();
};
The difference here is small but practical: an AsyncResult alias, concise error codes, and ok() handling void naturally.
Idea 2: Honest dependencies
The dependency-in-signature pattern is excellent. awaitly supports that same shape in two levels.
Shape 1: run + ErrorsOf
Plain functions, with no framework registration:
import { ok, err, type AsyncResult, type ErrorsOf } from 'awaitly';
import { run } from 'awaitly/run';
const validateEmail = async (
email: string,
): AsyncResult<string, 'INVALID_EMAIL'> =>
isValidEmail(email) ? ok(email) : err('INVALID_EMAIL');
const findUser = async (email: string): AsyncResult<User | null, 'DB_ERROR'> =>
ok(await db.findUserByEmail(email));
const checkNotTaken = async (
user: User | null,
): AsyncResult<void, 'EMAIL_TAKEN'> => (user ? err('EMAIL_TAKEN') : ok());
const createUser = async (
input: CreateUserInput,
): AsyncResult<User, 'DB_ERROR'> => ok(await db.createUser(input));
const sendWelcome = async (
email: string,
): AsyncResult<void, 'EMAIL_SERVICE_DOWN'> =>
ok(await emailService.sendWelcome(email));
const deps = {
validateEmail,
findUser,
checkNotTaken,
createUser,
sendWelcome,
};
type SignupError = ErrorsOf<typeof deps>;
const result = await run<User, SignupError>(async ({ step }) => {
const email = await step('validate', validateEmail(rawEmail));
const existing = await step('find', findUser(email));
await step('checkNotTaken', checkNotTaken(existing));
const user = await step(
'create',
createUser({ email, passwordHash: await hash(password) }),
);
await step('welcome', sendWelcome(email));
return user;
});
You can also keep fn(args, deps) exactly:
type SignupDeps = typeof deps;
type SignupErrors = ErrorsOf<SignupDeps>;
const signupUser = async (
args: { email: string; password: string },
deps: SignupDeps,
) => {
return run<User, SignupErrors>(async ({ step }) => {
const email = await step('validate', deps.validateEmail(args.email));
const existing = await step('find', deps.findUser(email));
await step('checkNotTaken', deps.checkNotTaken(existing));
const user = await step(
'create',
deps.createUser({ email, passwordHash: await hash(args.password) }),
);
await step('welcome', deps.sendWelcome(email));
return user;
});
};
Testability stays the same as manual style: inject fake deps directly.
Shape 2: createWorkflow for when you need more
import { createWorkflow, ok, err, type AsyncResult } from 'awaitly';
const signup = createWorkflow('signup', {
validateEmail,
findUser,
checkNotTaken,
createUser,
sendWelcome,
});
const result = await signup.run(async ({ step, deps }) => {
const email = await step('validate', deps.validateEmail(rawEmail));
const existing = await step('find', deps.findUser(email));
await step('checkNotTaken', deps.checkNotTaken(existing));
const user = await step(
'create',
deps.createUser({ email, passwordHash: await hash(password) }),
);
await step('welcome', deps.sendWelcome(email));
return user;
});
The error union is inferred automatically from registered functions, with no separate ErrorsOf<...> type needed. You also get step caching, retry/timeout config, and observability events.
Workflows can also be statically analyzed with awaitly-analyze, which lets you generate diagrams like this. The result is living documentation that makes onboarding and code reviews easier.
flowchart LR VE["validateEmail"] -->|ok| FU["findUser"] FU["findUser"] -->|ok| CNT["checkNotTaken"] CNT["checkNotTaken"] -->|ok| CU["createUser"] CU["createUser"] -->|ok| SW["sendWelcome"] SW["sendWelcome"] -->|ok| Done((Success)) VE -->|err| VEE["'INVALID_EMAIL'"] FU -->|err| FUE["'DB_ERROR'"] CNT -->|err| CNTE["'EMAIL_TAKEN'"] CU -->|err| CUE["'DB_ERROR'"] SW -->|err| SWE["'EMAIL_SERVICE_DOWN'"]
Idea 3: Composition
Manual andThen and early-return flows are valid and readable for small chains. As chains grow, composition noise grows too.
const result = await run(async ({ step }) => {
const email = await step('validate', validateEmail(rawEmail));
const existing = await step('find', findUser(email));
await step('checkNotTaken', checkNotTaken(existing));
const account = await step('create', createAccount(email));
await step('welcome', sendWelcome(account.id));
return account;
});
One gap manual Result often leaves open
The manual approach models expected errors well. A common gap is unexpected throws from dependencies.
const result = await run(async ({ step }) => {
const email = await step('validate', validateEmail(rawEmail));
const existing = await step('find', findUser(email));
await step('checkNotTaken', checkNotTaken(existing));
// Imagine createAccount throws internally
const account = await step('create', createAccount(email));
return account;
});
// result.error:
// "INVALID_EMAIL" | "EMAIL_TAKEN" | "DB_ERROR" | UnexpectedError
You can still build this boundary manually (runSafe wrapper). awaitly includes it by default.
step.try() for throwing APIs
const result = await run(async ({ step }) => {
const data = await step.try('parse', () => JSON.parse(rawInput), {
error: 'PARSE_FAILED' as const,
});
const response = await step.try('callSDK', () => thirdPartySDK.call(data), {
onError: (e) => ({
type: 'SDK_FAILED' as const,
message: e instanceof Error ? e.message : String(e),
}),
});
return response;
});
The trade-off is one more concept (step.try), in exchange for not re-writing try/catch-and-map code around every throwing dependency.
Scorecard
| Dimension | Raw async/await | Manual Result | awaitly |
|---|---|---|---|
| Setup cost | None | Result type, ok/err helpers, assertNever |
npm install, learn run/step/ErrorsOf |
| Per-module cost | Low | Error union + early returns per call | step() calls |
| Error visibility | None (catch unknown) | Expected errors typed | Expected + unexpected typed |
| Error union maintenance | N/A | Hand-maintained | Inferred (ErrorsOf<typeof deps> or createWorkflow) |
| Unexpected errors | Uncaught rejections | Usually uncaught unless you add safe boundary | UnexpectedError in union |
| Composition at scale | try/catch nesting | andThen nesting or early returns |
step() |
| Dep injection | Module imports | fn(deps, ...args) |
fn(args, deps) or createWorkflow |
| Test story | Often mocks/stubs | Pass fake deps | Pass fake deps |
Takeaway
The original post teaches the right principles and is honest about trade-offs. awaitly is a lightweight extension on top: infer error unions with ErrorsOf, reduce repeated early-exit checks with step(), and include a default boundary for unexpected throws.
The ideas are similar. The difference is where the ceremony lives: hand-written per module, or centralized behind a small execution boundary.