Effect-TS vs fp-ts: Functional Programming in 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 |
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)
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 |
Compare Effect-TS, fp-ts, and related packages on PkgPulse.