# Algebraic Thinking Without the Ceremony

By Jag Reehal, 2026-04-15. Canonical: https://arrangeactassert.com/posts/algebraic-thinking-without-the-ceremony/

<!-- Excerpt Start -->

Christian Ekrem's post, [_Effect Without Effect-TS: Algebraic Thinking in Plain TypeScript_](https://cekrem.github.io/posts/effect-without-effect-ts/), shows something useful: typed errors, explicit dependencies, and composition do not require a framework.

This post takes the same principles and shows where [awaitly](https://jagreehal.github.io/awaitly/) helps once the manual approach starts to repeat. The point is not "no ceremony."

The point is where ceremony lives.

<!-- Excerpt End -->

For most code, `run()` is the starting point. `createWorkflow()` is for orchestration features like retries, caching, observability, or managed dependency wiring.

```text
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

```typescript
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:

```typescript
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

```typescript
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:

```typescript
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:

```typescript
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

```typescript
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](https://jagreehal.github.io/awaitly/guides/static-analysis/), which lets you generate diagrams like this. The result is living documentation that makes onboarding and code reviews easier.

```mermaid
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.

```typescript
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.

```typescript
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

```typescript
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.