Monday, November 24th, 2025

Why We Don't Use Effect-TS

Nathan Leung
Engineering

We embrace functional programming patterns and principles throughout our full-stack TypeScript codebase, making ample use of libraries and tools like ts-pattern, Jotai, and Terraform to write declarative, composable, and type-safe code. That said, we don't use Effect-TS, and we don't have any plans to. Here's why.

Isometric 3d render of a wireframe workshop split in two levels. Ground floor: straightforward assembly line with lambda (λ) symbols, basic geometric shapes flowing smoothly on conveyor belts. Upper floor: sophisticated effect factory with monadic transformers, beautiful complex machinery with gears and pipes, but a complicated freight elevator struggling to move pieces between floors, some parts incompatible with ground floor specs. Workers (small wireframe figures) scratching heads at the elevator junction. Purple and gold with bright green successful connections, red at friction points, industrial blueprint aesthetic

Functional programming (FP) principles are the core of our engineering philosophy at Harbor. We've previously written about how we use:

  • Jotai: a React state management library whose underlying principle involves the composition of small, easily testable, atomic pieces of state. We describe our overall application state in terms of these composable atoms, and under the hood, Jotai handles the imperative orchestration required to keep the state in-sync across the entire application.
  • Terraform: a tool for declaratively managing infrastructure as code. Instead of writing imperative scripts to provision and configure our servers, we simply describe the desired state of our infrastructure, and Terraform figures out what it needs to do to get us there.

Much has been written about the benefits of functional programming principles, and we could go on and on about immutability, purity, composability, and how it enables us to write code that is easier to reason about, easier to test, easier to maintain... you probably know where this goes.

But there's also a personal reason that motivates our adoption of FP: long before starting Harbor, my first paid software job was at a full-stack Haskell shop, where the entire application — backend and frontend — was written in Haskell.1 It was my first exposure to professional software engineering. While I'll admit that I probably didn't fully appreciate the monument to functional programming they'd built at the time, I definitely learned a lot, and over the years, as I've worked at various other companies — building everything from Kotlin backend services (making ample use of if-expressions and isolating pockets of mutation with scope functions) to complex React frontends (where the primary mental model is that components are simply functions of state) — practical, personal experience has given me a deep appreciation for the functional perspective and its benefits.

Today, at Harbor — where we have a full-stack TypeScript monorepo — this means that we prefer const over let whenever possible,2 immutable Array methods over loops, and composition of small Lodash functions over writing our own imperative utilities. We use lint rules like no-floating-promises to forbid unhandled side effects. And in addition to Terraform and Jotai, we use libraries like ts-pattern and Zod to keep our code declarative and give the compiler as much information as possible, enabling more bugs to be caught at build-time rather than at runtime:

import { match } from "ts-pattern";

export async function fileExists(
  storageBucketBackend: StorageBucketBackend,
  storageBucketName: string,
  storageBucketPath: string,
) {
  return await match(storageBucketBackend)
    .with(STORAGE_BUCKET_BACKEND.MINIO, async () => {
      try {
        await minioClient.statObject(storageBucketName, storageBucketPath);
        return true;
      } catch {
        return false;
      }
    })
    .with(STORAGE_BUCKET_BACKEND.GCS, async () => {
      const [exists] = await googleCloudStorageClient
        .bucket(storageBucketName)
        .file(storageBucketPath)
        .exists();
      return exists;
    })
    // Ensures that we don't forget to update this function
    // if we add a new storage backend.
    .exhaustive();
}

In many ways, we've adopted functional programming to the extent possible within the constraints of a JavaScript/TypeScript codebase. Nonetheless, there are still areas for improvement. While I haven't had the pleasure of writing Haskell professionally since that first job, I still yearn for generic monadic do-notation whenever I'm awaiting a Promise, wish I could reach for Reader when I'm manually instantiating and wiring dependencies between our tRPC routers and our lower-level service methods, and think of how much more ergonomic it would be to handle errors with Either (or Rust's Result) instead of defensively wrapping everything in try/catch.

Luckily for me, there's a TypeScript library for all that! So why don't we use it?


For the uninitiated, Effect-TS (or more succinctly, "Effect") is a TypeScript framework that brings the aforementioned advanced FP features (and more) to TypeScript.3 More precisely, its main selling points are that it:

  • Allows side effects (which they call "effects") to be tracked in the type system by encoding external dependencies directly into return types (e.g. if you make an HTTP request in Effect, the fact that it is an HTTP request will be part of the return type signature — not just the JSON or text response itself).
  • Provides various utilities and types that might typically be found in the standard library of a purely functional programming language; in the context of the library, this enables developers to precisely control, compose, and handle outputs and errors arising from such "effects."

The main tradeoff — at least with respect to the code itself — is that instead of writing regular JavaScript, all effect-ful functions must be wrapped in Effect-specific wrappers and control flow (e.g., Effect.tryPromise, Effect.gen, etc.). This results in code that is markedly different from "normal" JavaScript:

import { Effect } from "effect"

const sleep = (ms: number) => new Promise(res => setTimeout(res, ms))

const sleepAndReturn = (ms: number, n: number) =>
  Effect.tryPromise({
    try: async () => {
      await sleep(ms)
      return n
    },
    catch: (e) => new Error(String(e))
  })

// "Wait 500ms, then return 42"
const program = Effect.gen(function*(_) {
  const x = yield* _(sleepAndReturn(500, 42))
  yield* _(Effect.log(`done, got ${x}`))
})

Effect.runPromise(program)

Notably, wrapped "effects" cannot be run directly. Instead, they need to be passed into Effect's "runtime" (e.g., with Effect.runPromise). This is somewhat analogous to React DOM's render function, but instead of accepting and rendering a component tree, the "runtime" accepts a sequence of effects and executes them.

Notwithstanding the strange syntax, this does introduce type safety to a place where TypeScript, despite its power and expressiveness, is otherwise lacking. And the programming model is theoretically sound and respects functional patterns (practitioners might recognize Effect.gen as a do block and yield* as syntactic sugar for monadic bind). So in theory, adopting Effect should result in all of FP's concomitant benefits — composability, clarity, testability, etc.

In practice, though, the answer is not so clear.

Greenfield

The first time we considered adopting Effect was at the very beginning, when the Harbor monorepo had zero lines of code. From a technical standpoint, I was already sold — functional programming is great! With respect to a few other axes, though, there were reservations.

Ecosystem and Interop

The product at my first startup job was written entirely in Haskell. From a programming standpoint, it was as purely functional as could be.4 If it compiled, it ran.

On the other hand, the full-stack Haskell ecosystem was abysmal compared to something like npm or PyPI. We often had to roll our own code instead of bringing in a battle-tested package from the community. Documentation was sparse and esoteric. On the frontend, bridging the gap between pure Haskell and the imperative DOM was a constant source of friction.

At my second startup job, part of why I chose to use Kotlin on the backend was because it offered the benefits of a thoughtfully-designed, modern, ergonomic, and safe language without having to give up a thriving ecosystem; Kotlin is JVM-based and can interoperate with existing Java code. Even so, interop with other Java libraries wasn't always perfect, which further emphasized to me the potential pitfalls of using newer languages and frameworks.

Effect, despite being written in TypeScript, felt vulnerable to similar issues. The ecosystem is still young, which means that there aren't very many Effect-native libraries. We'd likely have to write a lot of our own interop code, wiring every function call up into Effect's world — increasing tedium, muddling control flow, and making our codebase harder to reason about and maintain (debugging? stack traces?). At least at the start, being able to stand on the shoulders of giants and leverage existing libraries and tools as they were meant to be used felt more important than the benefits of a perhaps more "principled" and "pure" programming model.

Hiring and Onboarding

The CEO of the full-stack Haskell startup was nontechnical and had heard about the language from a friend of his who was a CS professor. The lead engineer, who was ex-Jane Street, taught me many things that summer about category theory, most of which went over my head.

From what I gathered, the CEO had been finding it difficult to hire programmers who possessed both the ability to work within the codebase and the willingness to get their hands dirty with a pre-product-market-fit software product. From personal experience, those who know enough about functional programming to actually apply its more advanced concepts properly either (a) vigorously prioritize theoretical elegance over immediate practicality, or (b) are taking home obscene amounts of cash from Rob Granieri's money printer. I can see why it might've been difficult to find suitable candidates.

Admittedly, the learning curve for Effect is likely not as steep as that of Haskell. After all, it's just TypeScript — we don't necesarily need to hire "Effect programmers" specifically. Even so, the Effect model is a significant departure from the way TypeScript is typically written, and if we wanted to hire maximally productive engineers, we'd need to either (a) hire people who already have enough experience with Effect (or with FP in general) and enjoy working in the paradigm and are effective in it and want to work for a startup (rare), or (b) in addition to getting new hires up to speed on the business domain and the rest of the stack, invest in comprehensive training in the library as well (time-consuming).

If simply using TypeScript like a normal person (instead of using Effect) means that the pool of capable, immediately productive candidates we can hire is significantly larger, that practical cost starts to weigh heavily on the theoretical benefits.

Brownfield

So, we ended up deciding not to adopt Effect at the very start. But as we began building out our MVP and made more progress on product development, the growing scope of the application tempted us to reconsider our decision. Perhaps more wholeheartedly adopting functional programming patterns could help us better manage the increasing complexity of the codebase.

By this point, we had a strong foundation already laid by other frameworks: our frontend was built with Next.js and our backend ran on Express. We handled authentication with Passport.js, defined our routes with tRPC, and accessed the database with Drizzle ORM. All together, we had full end-to-end type safety from the client to the persistence layer. Our code was written in a functional style, with FP principles in mind — but not dogmatically pure. It would be understandable to a developer even without any prior experience with FP.

The critical piece we felt we were missing from the FP world, though, was a Result type for error handling, similar to Rust's:5

import { ok, err, Result } from "neverthrow";

class RandomizationServiceError extends Error {}

type RandomizationError = RandomizationServiceError;

export async function randomizeSubject(
  subjectId: string
): Result<{ arm: string }, RandomizationError> {
  const response = await callIwrsSystem(subjectId);

  if (!response.success) {
    // Instead of `throw`ing, we return an `err`
    return err(new RandomizationServiceError(response.message));
  }

  return ok({ arm: response.arm });
}

// Then later, we can check if the result is an `err`
const result = await randomizeSubject(subjectId);
if (result.isErr()) {
  // Do something with the error.
  return;
}

// Since we've checked `isErr`, TypeScript narrows
// the type and we can safely access the value. The
// type system forces us to handle the error!
console.log(result.value)

Result felt like a good entrypoint to adopting more opinionated FP patterns. Up until this point, our approach to FP was more "local" and relatively permissive: we tried our best to adhere to FP principles broadly, but dropping a bit of mutation or a bit of imperative code here and there wouldn't affect other parts of the program. Adopting Result for error handling, on the other hand, would be a more "global" change that would require callers across the entire repository to adapt to the new pattern. It would give us a taste of what more aggressively enforcing functional purity across the entire codebase might look like.

While we assessed adding Effect as a dependency right then and there, we wanted to run a focused experiment. After surveying the ecosystem, we landed on neverthrow — a TypeScript library that provided exactly the lightweight Result type we wanted. If we wanted to migrate to Effect later, we could swap out neverthrow with minimal API changes; if we wanted to stick with neverthrow only, this would prevent us from introducing Effect-specific code when we didn't have full buy-in from the team.

At first — in isolation — neverthrow worked wonderfully. Then, we tried to integrate it with the rest of the stack:

import { db } from "./db";
import { randomizeSubject } from "./randomize-subject";

export async function handleRandomization(userId: string, subjectId  : string) {
  return await db.transaction(async (tx) => {
    const result = await randomizeSubject(subjectId);

    if (result.isErr()) {
      // We *need* to `throw` here to trigger a rollback.
      // Otherwise, the transaction will `COMMIT` even
      // though the randomization failed.
      throw result.error;
    }

    await tx.subjects.update({
      arm: result.value.arm,
    }).where(eq(subjects.id, subjectId));

    await tx.auditLogs.insert({
      subjectId,
      event: "randomized",
      arm: result.value.arm,
      createdAt: sql`now()`,
    });
    
    return result;
  });
}

The issues, unsurprisingly, were mostly related to interop:

  • Drizzle transactions only ROLLBACK on uncaught errors — so we would have to unwrap our Results and re-throw errors within every transaction block for transactions to actually work properly.
  • Sentry's global error handler is designed to capture uncaught Express errors — so inside every route handler, we would need to unwrap our Results and re-throw in order for exceptions to be properly captured and reported.
  • Passport.js requires authentication errors within strategies to be passed as the first argument of a callback — so all of our authentication strategies would need boilerplate to unwrap Results and call cb(err) at every potential point of failure.6

In short, the Node.js ecosystem is built on promises that reject and functions that throw, and ecosystem libraries expect this. By returning a Result, we break these assumptions, and pay the price in boilerplate.

Importantly, this experience foreshadowed what we might have to deal with if we ended up going all-in on Effect: the issues would be the same, but magnified. Instead of boilerplate to check isErr, unwrap, and re-throw, we'd be calling Effect.runSync or Effect.runPromise whenever we needed to bridge between Effect-aware and non-Effect-aware code: in every database transaction, API route handler, and authentication strategy. We would be working against our own language and tech stack.

And with that realization, we ended the experiment.

Conclusion

In "Beating the Averages", Paul Graham famously argues that building his startup using a relatively esoteric language (Lisp) was a competitive advantage: it was so high-level that it enabled them to ship features faster than their competitors and keep their team lean.

I do see where he's coming from. Functional programming is a powerful paradigm, and programmers who understand its principles tend to write safe, composable, and maintainable code that lends itself well to building complex software more quickly and reliably. If my main goal is to hire programmers who are capable of thinking and working in this paradigm, who can also be massively more productive in it, perhaps being opinionated and adopting Effect achieves that outcome.

And of course, hiring programmers who can think this way is one of our goals at Harbor. But we also want to build a great product and business, and we won't get there by forcing great programmers to reinvent the wheel and fight their tools. Graham's essay was published in 2001, and the world has changed a lot since then. We don't have to write our own web servers and database drivers anymore, and frankly, software engineering nowadays isn't so much so greenfield development as it is integrating with existing systems and libraries.

Some might say we didn't actually give Effect a fair shake. After all, we never fully adopted it. We ran a small experiment with a related library and extrapolated some conclusions. If we had infinite time, maybe we could've sat down and really followed through with a full integration. But we don't have infinite time, and in making technical decisions, my job isn't necessarily to pick the absolute best libraries and frameworks, but rather to draw on my observations and previous technical experiences to make good decisions under imperfect information.

That is, in the end, it comes down to pragmatism. We are building a business. Technology will not necessarily be the reason why we succeed — I would like it to at least be part of why we succeed — but it can very much be the reason we fail. It is my job to avoid that.

Footnotes

  1. The frontend was compiled to JavaScript using GHCJS. It was a fascinating technical experience. I learned how to write code without loops, and that random array access can sometimes be O(n) — since in Haskell, "arrays" are actually linked lists.

  2. If let is absolutely necessary, we'll generally isolate its scope to an IIFE and assign the result to a const in the outer scope.

  3. It is the successor to fp-ts, if you've heard of that library.

  4. They even used Nix to deploy to NixOS servers on AWS.

  5. In Effect (and Haskell), it's called Either.

  6. Part of this is by design; neverthrow is supposed to force you to handle all possible errors at the point where they occur. But when a library's API is designed such that errors should be thrown and handled by the library at a higher level, this constraint results in a bunch of noise. For what it's worth, neverthrow also provides safeTry to reduce the boilerplate, but it uses the same strange generator syntax as Effect.gen, and doesn't change the fact that we'd need to use it at every bridge between neverthrow-aware and non-neverthrow-aware code. A language-native feature like Rust's ? operator would be ideal here.

Footnote