Your TypeScript function signature is lying to you.

async function createShortLink(url: string): Promise<string>

Looks honest, right? Give it a string, get a string back. Except this function can also throw a validation error. Or a database error. Or a “slug already taken” error. Or a network timeout wrapped in three layers of framework exceptions. None of that is in the signature. The compiler — the whole reason you chose TypeScript — has no idea. Your callers have no idea. You won’t have any idea either, six months from now, at 3am, reading a stack trace.

We spent two decades adding types to JavaScript and then left the single most important thing — what can go wrong — completely untyped.

There’s a better way. It’s called railway-oriented programming, the TypeScript ecosystem has a production-grade library for it called Effect, and I’ve packaged the entire discipline into an agent skill — plus a real, working, tested e-commerce application that proves it holds up outside of blog-post snippets.

This is the long one. Get coffee.


The Problem: Exceptions Are Invisible Control Flow

Every throw in your codebase is a hidden goto. It jumps to… somewhere. Maybe a catch three files away. Maybe an Express error middleware. Maybe nowhere, and your process dies.

The deeper problem: exceptions don’t compose. When you write this:

const user = await getUser(id);
const cart = await getCart(user);
const order = await checkout(cart);

you’re reading the happy path. The failure paths — and there are at least six of them here — are invisible. try/catch doesn’t fix this; it makes it worse:

try {
  const user = await getUser(id);
  const cart = await getCart(user);
  const order = await checkout(cart);
} catch (e) {
  // e is `unknown`. Which of the six failures is it?
  // Time to play instanceof roulette.
}

One catch block, six distinct failures, zero compiler help. Was it a missing user (expected — return 404)? Insufficient stock (expected — tell the customer)? Or did the database connection die (a defect — page someone)? These deserve completely different handling, and the language gives you a single untyped funnel.


The Idea: Put Errors on Their Own Track

Railway-oriented programming — the name comes from Scott Wlaschin’s legendary F# talks — models every operation as a section of two-track railway:

            ┌────────────┐     ┌────────────────┐     ┌────────────┐
 Input ────▶│  validate  │────▶│ reserve stock  │────▶│   charge   │────▶ Success
            └─────┬──────┘     └───────┬────────┘     └─────┬──────┘
                  │                    │                    │
                  ▼                    ▼                    ▼
             ParseError       InsufficientStock      PaymentDeclined ───▶ Failure

Every function has two outputs: the success track and the failure track. Compose functions and the tracks connect automatically — a failure anywhere switches you to the error rail, bypassing everything downstream. No throw. No jumps. Failure is just data taking the other track.

The payoff is that errors become values with types. And once errors have types, the compiler does what compilers do: it tells you what you forgot to handle.


Enter Effect: The Signature That Tells the Truth

Effect is the railway, industrialized. Its core type has three slots:

Effect<Success, Error, Requirements>
  • Success — what you get on the happy track
  • Error — every expected failure, as a typed union
  • Requirements — what capabilities this needs (more on this later; it’s the sleeper feature)

Here’s that lying function from the intro, rewritten so the signature tells the whole truth:

const createShortLink = (input: string) =>
  Effect.gen(function* () {
    const target = yield* Schema.decodeUnknown(TargetUrl)(input);
    const slug = yield* generateSlug;
    const repo = yield* LinkRepo;
    yield* repo.save(slug, target);
    return slug;
  });

// Inferred type — no annotations needed:
// Effect<Slug, ParseError | SlugTaken, LinkRepo | Random>

Read that inferred type again, because it’s the entire pitch in one line:

  • It returns a Slug (not just any string — hold that thought)
  • It can fail with ParseError or SlugTaken — and nothing else
  • It needs a LinkRepo and a Random capability to run

The Effect.gen + yield* style reads exactly like the async/await you already write. The difference is what happens underneath: every yield* is a railway junction. If decodeUnknown fails, the whole thing short-circuits to the error track with a ParseError — and the type system knows.

Errors themselves are plain tagged classes:

class SlugTaken extends Data.TaggedError("SlugTaken")<{
  readonly slug: Slug;
}> {}

And handling them is exhaustive, where you choose, usually at the edge of the app:

program.pipe(
  Effect.catchTags({
    ParseError: () => badRequest("That's not a URL."),
    SlugTaken: (e) => conflict(`"${e.slug}" is taken.`),
  })
);
// Handle both → the error channel becomes `never`. Compiler-verified done.

Forget one? Compile error. Add a new failure mode next quarter? Every unhandled call site lights up. That 3am stack trace becomes a 3pm red squiggle.


It Goes Deeper Than Errors

Railway-oriented error handling is the gateway drug. The full discipline has four more pillars, and they reinforce each other.

1. Make illegal states unrepresentable

That Slug type above isn’t a string. It’s a branded type:

const Slug = Schema.String.pipe(
  Schema.pattern(/^[a-z0-9]{6}$/),
  Schema.brand("Slug")
);
type Slug = typeof Slug.Type;

You cannot pass a raw string where a Slug is expected — the only way to get a Slug is to go through the validation that defines it. An entire category of bugs (“someone passed the user ID where the order ID goes”) stops compiling.

Same for state. No more isLoading + isError + data boolean soup where three flags can describe eight states and five of them are nonsense. Tagged unions make exactly the valid states expressible — and Match forces you to handle all of them.

2. Parse, don’t validate

Untrusted data — HTTP bodies, database rows, environment variables — crosses the boundary through Schema.decodeUnknown exactly once, transforming into rich domain types. Past that point, the core of your app never sees raw input. No defensive if (x != null) sprinkled everywhere. The boundary parses; the core trusts its types, because the types earned it.

3. Time and randomness are dependencies too

Here’s the sleeper feature — that third type parameter. In Effect, you don’t call Date.now() or Math.random() directly; you ask the Clock and Random services. Which sounds like ceremony until you see what it buys you in a test:

it.effect("links expire after 60 minutes", () =>
  Effect.gen(function* () {
    const slug = yield* createShortLink("https://mikezupper.com");
    yield* TestClock.adjust("61 minutes");   // ← time travel
    const result = yield* resolveLink(slug).pipe(Effect.flip);
    expect(result._tag).toBe("LinkExpired");
  }));

That test fast-forwards 61 virtual minutes and completes in microseconds. No setTimeout. No flaky sleeps. No mocking library monkey-patching globals. Time is just a dependency, and tests supply a deterministic one.

4. Functional core, managed shell

Pure domain logic stays effect-free. Workflows compose those pure pieces with Effect. Infrastructure — database, HTTP, external APIs — lives behind typed service interfaces, wired together with Layers at a single entry point. Swap the real database layer for an in-memory one and your whole test suite runs against fakes that are values, not mocks.


The Skill: This Discipline, Enforced

Knowing all this and doing it consistently are different things — especially when an AI agent is writing half your code. Left alone, LLMs generate the TypeScript they were trained on: async/await, try/catch, throw new Error("oops"), as any when the compiler complains.

So I did what I did for HTML and CSS: wrote the discipline down as an agent skill.

effect-fp-skill teaches Claude Code (and any SKILL.md-compatible agent) to build TypeScript apps the railway way. Not as suggestions — as hard rules:

Banned Instead
async/await, .then() Effect.gen, pipe
throw, try/catch Tagged errors on the error channel
null/undefined in domain types Option<A>
Boolean state flags Data.TaggedEnum unions
as casts on external data Schema.decodeUnknown
process.env Config (secrets via Config.redacted)
Date.now(), Math.random() Clock / Random services
Mocking libraries Test Layers — fakes as values
Unbounded concurrency Explicit { concurrency: n }, Stream

The skill uses progressive disclosure: a ~150-line SKILL.md carries the philosophy, hard rules, and a 9-step build workflow, backed by 12 specialized reference guides — domain modeling, error taxonomy, services and layers, @effect/sql repositories, concurrency, property-based testing, production readiness, even a mandatory self-review checklist the agent runs against its own code. Everything pinned and verified against Effect 3.22.0.

Install (Claude Code):

git clone https://github.com/mikezupper/effect-fp-skill ~/.claude/skills/effect-fp-skill
rm -rf ~/.claude/skills/effect-fp-skill/.git

New session, ask for a TypeScript app, and the skill triggers automatically — or invoke it directly with /effect-fp-skill. (Codex and OpenCode read the same SKILL.md format — setup paths in my previous post.)


The Proof: A Real App, Not a Snippet

Here’s my problem with most FP-in-TypeScript content: it demos Option<number> on a calculator and calls it a day. Skills have the same credibility gap — instructions can sound rigorous and still produce garbage.

So I built effect-fp-skill-examples: three interconnected apps written under the skill’s rules, to validate that the discipline produces working software.

  1. shortlink — a URL shortener with expiring links. The complete loop — branded types, tagged errors, repository layer, HTTP API, deterministic tests — in about 250 lines. Start here.
  2. ecommerce — a full commerce backend: catalog with hierarchical categories, auth middleware, and a checkout that runs as an atomic transaction — reserve stock → snapshot prices → write order → clear cart, with automatic rollback if any step takes the failure track. The railway metaphor, load-bearing.
  3. ecommerce-ui — a server-rendered Lit storefront on top (platform-native routing over URLPattern, ~80 lines, no framework — but that’s a different post).

The part I care most about is the testing pyramid, because this is where the discipline pays rent:

  • Property-based tests (FastCheck) generate hundreds of adversarial inputs. One of them found that buildCategoryTree silently dropped categories that were their own parent — a bug no example-based test would ever have caught, because who writes that test case? A generator does.
  • Deterministic workflow tests prove expiry logic and checkout atomicity using TestClock — the 61-virtual-minutes trick above is from this repo, verbatim.
  • End-to-end (Playwright): register → browse → checkout → order history, asserting zero console errors the whole way.

And honestly — the failures were as valuable as the successes. Building these apps surfaced real gaps (recursive schemas need identifiers for OpenAPI generation; Lit SSR HTML-escapes serialized JSON in <script> tags; a shell component’s firstUpdated fires mid-hydration and can corrupt state). Every one of those findings got folded back into the skills. That’s the loop: the skill writes the app, the app improves the skill.

Run it yourself:

git clone https://github.com/mikezupper/effect-fp-skill-examples
cd effect-fp-skill-examples
npm run install:all
npm test        # typecheck + every suite
npm run dev     # API on :3001, storefront on :5173

Then open http://localhost:3001/docs and poke the Swagger UI.


Honest Tradeoffs

I don’t oversell — so, the costs:

  • There’s a real learning curve. Effect.gen reads like async/await, but Layer composition and the requirements channel take a week of feeling dumb. It passes.
  • The ecosystem is smaller than vanilla-Promise TypeScript. Effect’s platform packages (@effect/platform, @effect/sql, @effect/cli) cover a lot, but you’ll write the occasional adapter.
  • It’s a commitment. Half-adopting Effect — some functions throwing, some returning Effects — is worse than either extreme. The single-entry-point rule exists for a reason.

What you get back: signatures that tell the truth, failures the compiler tracks, tests that are deterministic by construction, and — the part that surprised me — AI agents that stop hallucinating error handling, because the type system rejects the vibes-based version before it ever runs.


Try It

Both repos are open: effect-fp-skill and effect-fp-skill-examples. Clone the skill, point your agent at a real problem, and read the code it produces — then go read the shortlink app and see what 250 lines of honest signatures feels like. If you find a gap — a rule that’s wrong, a pattern that’s missing, an example that breaks — open an issue or a PR. Credit where due: the railway metaphor belongs to Scott Wlaschin, and the heavy lifting to Effect’s maintainers.

I’m writing more of these — skills, the apps that stress-test them, and what breaks along the way. Follow me on X: @mikezupper to catch the next one.

Your functions are already failing. The only question is whether the compiler knows.