Effect-TS vs fp-ts for Functional TypeScript (2026)
TL;DR
Effect-TS has won the functional TypeScript space. fp-ts reached its peak and is in maintenance mode — the creator Giulio Canti moved to Effect. Effect provides everything fp-ts did (Option, Either, pipe) plus a full runtime for concurrency, error handling, dependency injection, and observability. The honest caveat: Effect has a steep learning curve and is complex enough that many teams don't need it. Effect shines for backend services with complex async flows, multiple failure modes, and DI requirements. For simple TypeScript: stick with native async/await + Zod.
Key Takeaways
- Effect-TS: ~200K downloads/week growing fast, successor to fp-ts with runtime
- fp-ts: ~700K downloads/week declining, maintenance mode, still used in older codebases
- Effect vs fp-ts: Effect = fp-ts data types + runtime scheduler + fiber-based concurrency
- When to use Effect: complex business logic with multiple error types, retry logic, dependency injection
- When NOT to use: simple CRUD APIs, small teams learning TypeScript, solo projects
- Mental model: Effect programs are descriptions of programs, not direct execution
Downloads
| Package | Weekly Downloads | Trend |
|---|---|---|
fp-ts | ~700K | ↓ Declining |
effect | ~200K | ↑ Fast growing |
@effect/schema | ~180K | ↑ Growing |
The Functional TypeScript Landscape in 2026
JavaScript's error handling story has always been awkward. try/catch swallows type information — you catch unknown and have no idea what failed. Async operations compound the problem: unhandled promise rejections are logged but not typed, and chaining optional operations through nullable values requires verbose null checks.
Two schools of thought emerged for handling this in TypeScript:
The "data types only" school (fp-ts, neverthrow): Add Option, Either, Result types that encode presence/absence and success/failure at the type level, without a runtime. fp-ts brought Haskell's monad toolkit to TypeScript — you get pipe, map, chain, fold, and a full set of algebraic data types. It works but carries Haskell's verbosity and learning curve.
The "full runtime" school (Effect-TS): Build a complete runtime that handles async scheduling, concurrency, resource management, dependency injection, and error handling in a single unified model. Effect is closer to Java's Vavr or Kotlin's Arrow — a complete functional effects system, not just data types.
The market voted decisively in 2025-2026. fp-ts is declining (~700K downloads/week, maintenance mode after its creator Giulio Canti moved to Effect). Effect is growing fast (~200K+/week, still under 700K but on a steep upward curve). For new projects choosing between them, Effect is the clear choice — unless you need fp-ts for compatibility with an existing codebase.
There's a third path worth knowing: neverthrow. At ~300K downloads/week, it's a minimalist Result type library — no monad imports, no pipe operator, just ok() and err() that return typed Result values. For teams that want explicit error handling without functional programming depth, neverthrow is a pragmatic choice.
fp-ts: The Foundation
fp-ts brought Haskell-style programming to TypeScript — Option, Either, pipe, and higher-kinded types.
// fp-ts: Option and Either
import { pipe } from 'fp-ts/function';
import * as O from 'fp-ts/Option';
import * as E from 'fp-ts/Either';
import * as TE from 'fp-ts/TaskEither';
// Option — represents a value that may or may not exist:
const findUser = (id: string): O.Option<User> => {
const user = db.users.get(id);
return user ? O.some(user) : O.none;
};
// Either — represents success or failure:
const validateEmail = (email: string): E.Either<string, string> => {
if (!email.includes('@')) return E.left('Invalid email');
return E.right(email);
};
// TaskEither — async with error handling:
const fetchUser = (id: string): TE.TaskEither<Error, User> =>
TE.tryCatch(
() => fetch(`/api/users/${id}`).then(r => r.json()),
(error) => new Error(String(error))
);
// pipe — compose operations:
const result = pipe(
E.Do,
E.bind('email', () => validateEmail(rawEmail)),
E.bind('user', ({ email }) => E.right({ email })),
E.map(({ user }) => user)
);
fp-ts works but has rough edges: awkward import patterns, complex generic types, no built-in concurrency.
Effect-TS: The Modern Successor
Effect thinks bigger: it's a full runtime, not just data types.
// Core Effect: describing a program
import { Effect, pipe, Option, Either } from 'effect';
import { Schema } from '@effect/schema';
// Define errors as types:
class UserNotFound extends Error {
readonly _tag = 'UserNotFound';
constructor(readonly id: string) { super(`User ${id} not found`); }
}
class DatabaseError extends Error {
readonly _tag = 'DatabaseError';
constructor(readonly cause: unknown) { super('Database error'); }
}
// Effect<Success, Error, Requirements>:
const findUser = (id: string): Effect.Effect<User, UserNotFound | DatabaseError> =>
Effect.tryPromise({
try: () => db.user.findFirst({ where: { id } }),
catch: (e) => new DatabaseError(e),
}).pipe(
Effect.flatMap((user) =>
user ? Effect.succeed(user) : Effect.fail(new UserNotFound(id))
)
);
Error Handling: Effect's Killer Feature
// Effect forces you to handle ALL error types explicitly:
const updateProfile = (userId: string, data: ProfileUpdate): Effect.Effect<Profile, UserNotFound | ValidationError | DatabaseError> =>
pipe(
validateProfile(data), // Effect<ValidData, ValidationError>
Effect.flatMap((valid) => // Chain — errors accumulate in union
findUser(userId).pipe(
Effect.flatMap((user) =>
Effect.tryPromise({
try: () => db.profile.update({ where: { userId }, data: valid }),
catch: (e) => new DatabaseError(e),
})
)
)
)
);
// Caller must handle all 3 error types:
const result = await Effect.runPromise(
updateProfile('user-123', newData).pipe(
Effect.catchTags({
UserNotFound: (e) => Effect.succeed({ error: `User ${e.id} not found` }),
ValidationError: (e) => Effect.succeed({ error: e.message }),
DatabaseError: (e) => Effect.fail(e), // Re-throw DB errors
})
)
);
Dependency Injection with Effect
// Services as Effect layers:
import { Context, Layer, Effect } from 'effect';
// Define service interfaces:
interface Database {
readonly findUser: (id: string) => Effect.Effect<User, UserNotFound>;
readonly updateProfile: (userId: string, data: ProfileUpdate) => Effect.Effect<Profile, DatabaseError>;
}
const Database = Context.GenericTag<Database>('Database');
// Provide real implementation:
const DatabaseLive = Layer.succeed(Database, {
findUser: (id) => Effect.tryPromise({
try: () => db.user.findFirst({ where: { id } }),
catch: (e) => new UserNotFound(id),
}).pipe(Effect.flatMap(u => u ? Effect.succeed(u) : Effect.fail(new UserNotFound(id)))),
updateProfile: (userId, data) => Effect.tryPromise({
try: () => db.profile.update({ where: { userId }, data }),
catch: (e) => new DatabaseError(e),
}),
});
// Provide test implementation:
const DatabaseTest = Layer.succeed(Database, {
findUser: (id) => Effect.succeed({ id, name: 'Test User', email: 'test@example.com' }),
updateProfile: (userId, data) => Effect.succeed({ ...data, userId }),
});
// Use service in program:
const program = Effect.gen(function* () {
const db = yield* Database;
const user = yield* db.findUser('user-123');
return user;
});
// Run with real or test implementation:
await Effect.runPromise(Effect.provide(program, DatabaseLive));
await Effect.runPromise(Effect.provide(program, DatabaseTest)); // For tests!
Concurrency with Effect Fibers
import { Effect, Fiber } from 'effect';
// Run multiple effects concurrently:
const fetchUserData = Effect.all([
fetchUser('user-123'),
fetchUserPosts('user-123'),
fetchUserSubscription('user-123'),
], { concurrency: 3 }); // All in parallel, typed tuple result
// Race effects — take the first to succeed:
const getCachedOrFetch = Effect.race(
fetchFromCache(userId),
fetchFromDatabase(userId),
);
// Retry with exponential backoff:
const resilientFetch = fetchUser(userId).pipe(
Effect.retry({
times: 3,
schedule: Schedule.exponential('100 millis'),
})
);
// Timeout:
const withTimeout = fetchUser(userId).pipe(
Effect.timeout('5 seconds'),
);
When Effect Is Worth It vs Overkill
Effect is WORTH IT when:
→ Backend service with multiple failure modes
→ Need dependency injection for testing (swap DB, email, etc.)
→ Complex concurrent operations with structured concurrency
→ Long-running services with retry/circuit breaker logic
→ Team is comfortable with functional paradigms
→ Enterprise codebases with strict error handling requirements
Effect is OVERKILL when:
→ Simple REST API (async/await + try/catch is fine)
→ Solo developer or small team
→ Prototyping or MVPs
→ Team is still learning TypeScript
→ Your app has 2-3 error types total
→ Next.js Server Actions with basic try/catch
Good middle ground (no Effect needed):
→ TypeScript + Zod validation
→ Result type (neverthrow library): ~300K downloads/week
→ Custom Either/Result pattern (20 lines of code)
Getting Started with Effect: The Learning Curve
Effect has the steepest learning curve of any mainstream TypeScript library. The concepts are powerful but require mental model shifts that take weeks to internalize.
Week 1: The basics. Effect.tryPromise, Effect.flatMap, Effect.all. Writing simple async functions that return Effect<Success, Error> instead of Promise<Success>. Understanding that Effects are descriptions of programs — an Effect<User, UserNotFound> doesn't run until you call Effect.runPromise().
Week 2: Error handling. Effect.catchTag for handling specific tagged errors. Effect.catchAll for fallbacks. Understanding the difference between expected failures (in the error channel) and unexpected defects (bugs that terminate the fiber). The discipline of tagging all error types with readonly _tag = 'ErrorName' pays off in catchTags handlers.
Week 3: Layers and dependency injection. This is where Effect becomes transformative for testability. Services defined as Context.Tag<Interface> with Layer.succeed(Tag, implementation) providers let you swap implementations for tests with no mocking library. Your tests inject DatabaseTest layer instead of DatabaseLive and the logic is exactly the same.
Week 4+: Concurrency and fibers. Effect.all with { concurrency: 'unbounded' } for parallel effects. Effect.race for taking the first successful result. Fiber.fork for background work. Schedule.exponential for retry logic. At this point, Effect starts feeling like a superpower for complex backend services.
The honest truth: most CRUD APIs don't need Effect. A Node.js API with 5 endpoints that reads from a database and calls a third-party API is well-served by async/await + Zod validation + custom error types. Effect's value emerges at the complexity threshold where managing multiple failure modes, concurrent operations, and testable DI would otherwise require significant custom infrastructure.
Comparison Table
| Effect-TS | fp-ts | neverthrow | |
|---|---|---|---|
| Downloads | 200K/week | 700K/week | 300K/week |
| Trend | ↑ Growing | ↓ Declining | → Stable |
| Runtime | ✅ Full runtime | ❌ Data types only | ❌ Data types only |
| Concurrency | ✅ Fibers | Manual | ❌ |
| DI system | ✅ Layers/Context | ❌ | ❌ |
| Learning curve | High | Medium | Low |
| Bundle size | ~150KB | ~40KB | ~5KB |
| Status | Active | Maintenance mode | Active |
Should Your Team Use Effect?
The honest answer for most teams is: probably not yet. Effect is powerful but complex, and that complexity is a real cost. Here's a decision framework that avoids both underselling and overselling it.
Effect is appropriate when: your backend service has complex business logic with 5+ distinct error types that all need different handling; you're building something that genuinely needs structured concurrency (running 20 database checks in parallel with individual error handling per check); you need testable dependency injection without a framework; or you have a team where at least 1-2 senior engineers are willing to become the Effect experts and document patterns for the rest.
Effect is not appropriate when: you have a team of 2-3 developers primarily focused on shipping features; your errors are mostly "this failed, show a toast" level; your application is primarily CRUD with straightforward data flows; or you're in a hiring environment where finding developers with Effect experience is difficult. Effect is still a niche skill in the broader TypeScript ecosystem.
The migration path from fp-ts to Effect is well-documented. Most fp-ts concepts map directly: O.Option<A> → Option.Option<A>, E.Either<E, A> → Either.Either<E, A>, TE.TaskEither<E, A> → Effect.Effect<A, E>. The main shift is moving from pure data types to using Effect's runtime for async execution.
fp-ts is still used in the large ecosystem of codebases built on it between 2019-2024. The libraries in the fp-ts ecosystem (io-ts, parser-ts, monocle-ts) remain widely used. If you're maintaining a fp-ts codebase, there's no urgency to migrate to Effect unless you specifically need its runtime capabilities.
The bottom line: Effect for complex backend services with senior TypeScript teams. neverthrow for teams wanting explicit error handling without the complexity curve. async/await + Zod for everything else.
The Mental Model Shift: Why Effect Is Hard at First
Effect's learning curve isn't about syntax — it's about a fundamental change in how you think about program execution. Understanding this shift upfront saves weeks of confusion.
In standard TypeScript, a function that does async work is a direct description of what to do: call this async operation, await the result, handle exceptions with try/catch. The code IS the execution.
In Effect, a function returns a description of what should happen — an Effect<Success, Error, Requirements> value that doesn't execute until you pass it to Effect.runPromise() or Effect.runSync(). This is called deferred execution or "lazy" evaluation.
// Standard TypeScript: this executes immediately when called
async function getUserAndPosts(userId: string) {
const user = await db.user.findFirst({ where: { id: userId } }); // Executes NOW
const posts = await db.post.findMany({ where: { authorId: userId } }); // Executes NOW
return { user, posts };
}
// Effect: this returns a description, doesn't execute yet
const getUserAndPosts = (userId: string) =>
Effect.all({
user: Effect.tryPromise(() => db.user.findFirst({ where: { id: userId } })),
posts: Effect.tryPromise(() => db.post.findMany({ where: { authorId: userId } })),
}, { concurrency: 2 });
// Nothing runs until here:
const result = await Effect.runPromise(getUserAndPosts('user-123'));
This indirection enables Effect's superpowers: you can compose, transform, retry, timeout, and test the description without running it. But it means every part of your program needs to either be an Effect or explicitly bridge to one.
The "Effect creep" experience is common: you add Effect to one service, then everything that calls that service needs to return Effect too, then you find yourself converting the entire codebase. Teams that adopt Effect do better when they decide upfront to use it throughout a service boundary rather than mixing it with async/await in the same codebase.
Getting Started with Effect Without the Full Runtime
Effect has three distinct uses that you can adopt independently. Many teams start with just the type safety layer before committing to the full runtime.
Use 1: Just Effect/Schema (formerly @effect/schema)
Effect Schema provides a data validation and transformation library that competes directly with Zod. The bundle size is similar (~80KB for Effect Schema vs ~60KB for Zod), the API is different, and the integration with Effect types is seamless:
import { Schema } from '@effect/schema';
const UserSchema = Schema.Struct({
id: Schema.String,
email: Schema.String.pipe(Schema.filter(s => s.includes('@'))),
age: Schema.Number.pipe(Schema.int(), Schema.positive()),
role: Schema.Literal('admin', 'user', 'moderator'),
});
type User = Schema.Schema.Type<typeof UserSchema>;
// Decode (validate + transform):
const result = Schema.decodeUnknownSync(UserSchema)({ id: '1', email: 'a@b.com', age: 25, role: 'admin' });
This is a meaningful upgrade from Zod for teams already using Effect — the schema types integrate perfectly with Effect's error handling. For teams not on Effect, Zod is simpler to adopt.
Use 2: Option and Either without the runtime
You can use Effect's Option and Either types as fp-ts drop-in replacements without adopting the full runtime. These give you explicit null handling and typed errors:
import { Option, Either } from 'effect';
// Option: explicit null handling
const findUser = (id: string): Option.Option<User> =>
Option.fromNullable(userMap.get(id));
// Either: typed errors without throw
const parseConfig = (raw: string): Either.Either<Config, ParseError> => {
try {
return Either.right(JSON.parse(raw));
} catch {
return Either.left(new ParseError(raw));
}
};
Use 3: Full Effect runtime for a bounded service
The most successful adoption pattern: choose one backend service (ideally one with complex async flows and multiple error types) and build it entirely with Effect. Don't try to migrate existing code — build new functionality with Effect and evaluate after 2-4 weeks of production use.
Alternatives for Teams Not Ready for Effect
If Effect's complexity is too high for your team's current TypeScript maturity, these are lighter-weight alternatives that address specific Effect use cases:
neverthrow (~300K weekly downloads, stable): Provides a Result type (similar to Rust's Result<T, E>) that makes errors explicit without a runtime. Significantly simpler than Effect; no fiber concurrency, no DI — just explicit error types.
import { ok, err, Result } from 'neverthrow';
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return err('Cannot divide by zero');
return ok(a / b);
}
const result = divide(10, 2);
if (result.isOk()) {
console.log(result.value); // TypeScript knows value is number
}
@total-typescript/shoehorn and similar custom Either/Result types**: For teams that want typed errors but don't want a dependency, a 20-line Result type implementation covers most use cases. Copy-paste code that you own completely.
Zod + structured try/catch: For validation and basic error handling, Zod's parse errors + a try/catch wrapper that returns typed objects is sufficient for most CRUD applications. This requires discipline to use consistently but no new dependencies or mental models.
The heuristic: if your service has 2-4 distinct error types and straightforward async flow, neverthrow or a custom Result type is enough. If you have 8+ error types, complex retry logic, and DI requirements, Effect is likely worth the learning investment.
Compare Effect-TS, fp-ts, and related packages on PkgPulse.
See also: Effect-TS vs fp-ts 2026 and Effect-TS vs fp-ts vs Neverthrow: TS Errors 2026, AI SDK vs LangChain: Which to Use in 2026.