Effect vs Nark for TypeScript Error Handling: When Each Fits
By Nark Team
Effect is a TypeScript library that tracks errors as values in the type system, so the compiler refuses to let an unhandled error slip through. Nark is a static scanner that flags missing error handling around 169+ npm packages in your existing TypeScript code. They sit at different layers of the same problem, and for most teams the right answer is "pick the one that matches where you actually are, and consider both later."
Quick Answer: Use Effect if you are starting a new TypeScript codebase (or a quarantined new module) and your team has the bandwidth to adopt a functional-effect programming model. Use Nark if you have an existing TypeScript codebase and want to find unhandled errors without rewriting your code. To scan a TypeScript project for missing error handling around npm packages:
npx nark --tsconfig ./tsconfig.json
What does Effect do for TypeScript error handling?
Effect (effect.website) is a TypeScript library that models programs as values of type Effect<A, E, R>, where:
Ais the success typeEis the error type (or union of error types)Ris the set of services the program needs
The error channel is tracked by the type system. If a function might fail with NetworkError | ValidationError, that union appears in its signature. The compiler refuses to let you discard, ignore, or forget those errors. You handle them with combinators (Effect.catchTag, Effect.catchAll, Effect.match) or you propagate them up the call stack as part of the type.
import { Effect } from "effect"
class NetworkError {
readonly _tag = "NetworkError"
constructor(readonly message: string) {}
}
class ValidationError {
readonly _tag = "ValidationError"
constructor(readonly field: string) {}
}
const fetchUser = (id: string): Effect.Effect<User, NetworkError | ValidationError> =>
Effect.gen(function* () {
if (!id) yield* Effect.fail(new ValidationError("id"))
const response = yield* Effect.tryPromise({
try: () => fetch(`/users/${id}`),
catch: () => new NetworkError("fetch failed"),
})
return (yield* Effect.tryPromise(() => response.json())) as User
})
// The compiler now knows this can fail with NetworkError or ValidationError.
// You cannot run fetchUser without handling them, or explicitly propagating them.
Effect also bundles structured concurrency (Fibers), retries and scheduling (Schedule), dependency injection (Layer), resource management (Scope), and schema validation (@effect/schema). Once you adopt it, the whole error model is uniform across the codebase, including async, sync, and concurrent code.
This is the gold standard for typed error handling in TypeScript. There is no real argument against Effect's correctness guarantees.
What does Nark do for TypeScript error handling?
Nark (nark.sh) is a static scanner. It does not change how you write code. You keep using try/catch, Promise.catch(), and async/await the way you already do. Nark parses your TypeScript with the compiler API and checks every call to a covered npm package against a Nark Profile that describes what that package can throw at runtime.
A Nark Profile is a YAML specification of an npm package's failure modes, sourced from the package's own changelog, GitHub issues, official docs, and production incident reports. Each Profile encodes postconditions like "calls to axios.get() can throw AxiosError with status codes in the 4xx-5xx range" or "calls to prisma.user.create() can throw PrismaClientKnownRequestError with code P2002 on duplicate unique fields."
// Nark flags this: bare axios call with no try/catch
const response = await axios.get<User>("/users/1")
// Nark flags this: try/catch present, but the catch does not check
// axios.isAxiosError and treats network and HTTP errors identically
try {
const response = await axios.get<User>("/users/1")
return response.data
} catch (e) {
console.error(e)
return null
}
// Nark passes this: error type is checked, status codes are handled
try {
const response = await axios.get<User>("/users/1")
return response.data
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response?.status === 404) return null
throw new Error(`Upstream error: ${error.message}`)
}
throw error
}
Nark covers 169+ npm packages (axios, Prisma, Stripe, Redis, OpenAI, pg, BullMQ, ioredis, and others) with curated Profiles. It runs locally or in CI. The output is a list of violations with file paths, line numbers, and the specific failure mode each violation misses.
Nark is not a runtime guarantee. It cannot prove your error handling is exhaustive across program composition the way Effect's type system can. What it can do: tell you exactly which calls in your existing codebase are unguarded, and stop the bug before merge.
Effect vs Nark: side by side
| Dimension | Effect | Nark |
|---|---|---|
| Layer | Runtime library / type system | Static scanner |
| Code change required | Yes (program model changes) | No |
| Errors tracked by | Type system (Effect<A, E, R>) | YAML Profile per npm package |
| Coverage scope | Anywhere you write Effect code | 169+ profiled npm packages |
| Catches errors at | Compile time | CI, pre-commit, or local run |
| Learning curve | Moderate to steep | None |
| Runtime cost | Small (Effect runtime, ~20-30KB) | Zero (scanner, ships nothing to production) |
| Greenfield project fit | Excellent | Good |
| Existing codebase fit | Hard (full migration) | Excellent (no migration) |
| Composition guarantees | Yes (Effect is a calculus) | No |
| Concurrency primitives | Yes (Fibers, Queue, STM, Stream) | No |
| DI / resource management | Yes (Layer, Scope) | No |
The two tools answer different questions. Effect's question is "how do I make this entire program impossible to compile if errors are not handled?" Nark's question is "in this large existing TypeScript codebase, which specific npm package calls are missing the error handling that package documents?"
Compile-time vs CI-time error tracking: The deepest decision behind "Effect or Nark" is when you want errors caught. Effect tracks errors at compile time through the type system, which means an unhandled error is a build failure before any test runs. Nark tracks errors at CI time by scanning your code against package failure modes, which means an unhandled error is a CI failure before any merge. Compile-time is the stronger guarantee; CI-time is the lower adoption cost. Most teams already running a CI pipeline can add Nark in a single workflow step. Teams starting a new codebase can adopt Effect from day one with no migration cost.
How does Effect compare to fp-ts, neverthrow, and ts-results?
Effect is not the only TypeScript library that gives you typed error handling. fp-ts, neverthrow, ts-results, and true-myth all sit somewhere on the spectrum between "do nothing special" and "full effect system."
The short version: Effect is the most ambitious and the most expensive to adopt. neverthrow and ts-results are the lightest weight and give you a Result<T, E> type without changing your programming model. fp-ts is the historical predecessor that the active community has migrated away from. true-myth is similar to neverthrow with a slightly different API.
| Library | Runtime model | Errors tracked at | Learning curve | Async support | Concurrency primitives | Status |
|---|---|---|---|---|---|---|
| Effect | Full effect system (Effect<A, E, R>) | Compile time | Moderate to steep | Native | Yes (Fibers, STM, Queue, Stream) | Active, the canonical FP-style TS library |
| fp-ts | Either<E, A> + module ecosystem | Compile time | Steep | Via TaskEither | No | Maintenance mode, maintainers moved to Effect |
| neverthrow | Result<T, E> + ResultAsync<T, E> | Compile time | Low | Via ResultAsync | No | Active |
| ts-results | Result<T, E> + Option<T> | Compile time | Low | Manual wrapping | No | Active but minimal |
| true-myth | Result<T, E> + Maybe<T> | Compile time | Low | Manual wrapping | No | Active |
When each fits:
- Choose Effect if you want the full effect system with structured concurrency, dependency injection, and resource management. The right choice for new services and infra-leaning teams.
- Choose neverthrow if you want typed errors without the full effect system. The right choice when you want
Result<T, E>semantics in regular async/await code without learning combinators. - Choose ts-results or true-myth for the same reason as neverthrow, with a preference based on API ergonomics. ts-results is the most minimal; true-myth has a richer Maybe type for nullable handling.
- Avoid fp-ts for new code. It is in maintenance mode and the ecosystem has moved on. If you have existing fp-ts code, plan a gradual migration to Effect.
Where Nark fits in any of these:
Nark is a static scanner, not a library. It is complementary to all four of these. In a neverthrow or ts-results codebase, Nark tells you which Result-returning calls miss the error path for specific npm packages (axios, Prisma, Stripe, etc.). In an Effect codebase, Nark scans the wrapper functions that translate package errors into Effect error types. The Profiles describe the failure modes regardless of how you choose to handle them in code.
When should you choose Effect?
Effect is the right choice when:
- You are starting a new TypeScript project from scratch. Adopting Effect on day one costs you the learning curve and nothing else. You get a uniform error model across the entire codebase.
- Your team has the bandwidth to invest in a functional effect system. Effect is not heavy relative to what it gives you, but it is unfamiliar to developers who have not used ZIO, Cats Effect, or fp-ts. Plan on a few weeks of ramp time.
- Your domain is concurrent, transactional, or stream-heavy. Effect's Fiber model, STM, Queue, and Stream primitives are genuinely best-in-class for TypeScript. If you are writing a job runner, a workflow engine, or anything with structured concurrency, Effect pays for itself fast.
- You want compile-time guarantees, not after-the-fact scanning. A type-tracked error channel is a stronger guarantee than any scanner can give you. If your team values that, Effect is the answer.
If you are at an infra-leaning company building a new TypeScript service, Effect is a serious choice. The community is active, the docs are improving, and the ecosystem (@effect/platform, @effect/sql, @effect/schema) is real.
When should you choose Nark?
Nark is the right choice when:
- You have an existing TypeScript codebase. Rewriting an existing codebase into Effect is a months-long migration. Most teams cannot afford that. Nark works on the code you have today.
- Your codebase uses async/await and promises. Nark is built around that model. There is nothing to learn beyond
npx nark. - You want CI to fail when error handling is missing. Nark drops into GitHub Actions, CircleCI, GitLab CI, or any other CI system with one workflow step.
- You want package-specific guidance. Nark knows what
AxiosError,PrismaClientKnownRequestError,StripeCardError, and 160+ other error types look like. It tells you the exact failure mode you missed, with file and line. - You want zero runtime cost. Nark runs before your code ships. There is no library to import, no runtime to deploy.
For most production TypeScript codebases that already exist, Nark is the path of least resistance to "we know our error handling is complete for the npm packages we depend on."
The middle ground: adopting Effect incrementally in an existing codebase
The framing of "greenfield or existing" misses a real third option. You can adopt Effect inside a single new module or service inside an existing codebase, while leaving the rest of the code untouched.
This pattern works well when:
- You are spinning up a new microservice and the rest of the codebase stays on async/await.
- You are adding a new domain (workflow engine, payment reconciliation, scheduled jobs) and want strong guarantees in that domain only.
- You want to evaluate Effect on a real workload before committing the whole team to it.
In this configuration:
- The new module uses Effect for its internal logic, with typed error channels and
Effect.gen. - The boundary functions that other parts of the codebase call return plain
Promise<Result>or throw conventionally, because that is what the existing code knows how to consume. - The legacy parts of the codebase continue to use async/await with try/catch.
This is where Effect and Nark actually pair well in the same project. Effect handles error correctness inside the new module. Nark scans the legacy surface (the parts that still call axios, Prisma, Stripe, etc. directly) and the boundary functions that translate package errors at the edges of Effect code.
Even inside an Effect codebase, you eventually call an npm package. Effect.tryPromise wraps that call, but the wrapper still has to know what the underlying package can throw and translate it into the Effect error type. Nark's Profiles describe those failure modes for 169+ packages, which is useful background reading whether or not the actual wrapping is done with Effect.tryPromise.
// Effect wrapper around axios. The Profile for axios tells you
// what error types you need to translate here.
const getUser = (id: string): Effect.Effect<User, NetworkError | NotFoundError> =>
Effect.tryPromise({
try: () => axios.get<User>(`/users/${id}`).then((r) => r.data),
catch: (error) => {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return new NotFoundError(id)
}
return new NetworkError(String(error))
},
})
If the wrapper does not handle axios.isAxiosError or misses the 404 case, the Effect type system cannot catch the gap, because the error type is what you declared it to be. Nark catches it because the Profile for axios describes the failure modes explicitly.
When should you use both?
The case for running both Effect and Nark in the same project:
- Mixed codebases. New module is Effect, legacy modules are async/await. Nark covers the legacy surface; Effect handles the new module.
- Boundary correctness. Any Effect program that calls external services still needs wrapper functions to translate failure modes. Nark's Profiles describe those failure modes, which is useful for writing correct wrappers.
- Defense in depth. Effect catches what its type system models. Nark catches what its Profiles describe. The overlap is small. The union is wider than either alone.
- CI safety net. Even a pure Effect codebase can have a developer write a bare
await axios.get(...)in a script, a test helper, or a quick prototype that escapes the type system because it was never wrapped. Nark catches that in CI.
For most teams the practical answer is: pick one as your primary, and add the other later if the boundary cases warrant it.
Learning curve and adoption cost compared
| Effect | Nark | |
|---|---|---|
| Time to first useful result | Days to weeks (ramp up on Effect concepts, rewrite some code) | Minutes (npx nark) |
| Time to onboard a new engineer | Real, measurable. They need to learn the effect system before they can ship. | Zero. They write normal TypeScript. |
| Time to migrate an existing 50K-line codebase | Months of dedicated work, or never (you live with a split codebase) | Hours (run the scanner, triage violations, suppress or fix) |
| Failure mode when used wrong | Engineer escapes the type system with Effect.runSync / Effect.runPromise and loses error tracking | Engineer adds the package to suppressions, errors slip through |
Both tools require ongoing maintenance. Effect requires keeping up with API changes in a young, evolving library. Nark requires updating Profiles as npm packages evolve. The Nark team maintains the Profiles; you do not write them unless you want custom ones.
Neither tool is free. The honest question is which cost you can absorb today.
Frequently Asked Questions
Is Effect a replacement for try/catch?
In an Effect codebase, yes. Effect's error channel and combinators replace try/catch entirely for code written inside Effect. Code at the boundary (calling Effect from non-Effect code, or vice versa) still uses Promise rejection conventions, and the wrappers around npm packages still need to translate package-thrown errors into Effect error types.
Does Nark work with Effect codebases?
Yes. Nark scans TypeScript source files. It does not care whether the code uses Effect or conventional async/await. In an Effect codebase, Nark is most useful on the wrapper functions that translate package errors into Effect error types, and on any code that calls npm packages outside Effect (tests, scripts, prototypes).
Is Effect's bundle size a concern for frontend apps?
Effect is tree-shakeable, and the core runtime is small (around 20-30KB minified). For most apps this is acceptable, but you should measure it for your specific case. Nark adds zero bundle size since it does not ship to production.
Can Nark check that I am using Effect correctly?
Not directly. Nark's Profiles are package-specific (axios, Prisma, Stripe, etc.), not framework-specific. For guarantees about correct Effect usage, you rely on Effect's type system, ESLint rules in the Effect community, and your own tests.
Which is more "type-safe"?
Effect, by a significant margin. Tracking errors at the type level is a stronger guarantee than static scanning. The tradeoff is that Effect requires you to write your code in Effect's style. Nark requires nothing.
What about ts-results, neverthrow, fp-ts, or true-myth?
See the dedicated comparison section above: How does Effect compare to fp-ts, neverthrow, and ts-results? Short version: neverthrow and ts-results are lightweight Result<T, E> libraries that do not require adopting a full effect system. fp-ts is in maintenance mode and the maintainers moved to Effect. true-myth is similar to neverthrow with a richer Maybe type.
Try It Now
npx nark --tsconfig ./tsconfig.json
Nark scans your TypeScript project against 169+ Nark Profiles covering the most-used npm packages. The output is a list of violations: file path, line number, and the specific failure mode each call misses.
If you are evaluating Effect, run Nark on your current codebase first. The output is a concrete map of where your unhandled errors live today, which gives you the data to decide whether a full Effect migration is justified, or whether closing the specific gaps Nark finds is enough.
If you are already on Effect, run Nark on the wrappers and the boundary code. The Profiles describe the failure modes your Effect.tryPromise calls need to translate.
Both tools are good. Pick the one that fits where you actually are.
Related reading: How to handle axios errors in TypeScript, How to handle Prisma errors in TypeScript, ESLint vs Semgrep vs Nark.