Typed error handling and resilient workflows for TypeScript — from a single Result<T, E> to lazy, dependency-injected ResultTask<T, E, R>.
Core Package · Gradual Adoption Guide · Full Reference (DOCUMENTATION.md) · resultar-check Compiler CLI/LSP · Benchmarks · AI / Agent Skill (llms.txt)
In standard TypeScript, throw and rejected promises are invisible in function signatures. Callers cannot tell what can fail—or whether every failure mode was handled—without reading the implementation of every transitive call.
// ❌ Standard TypeScript: the signature promises a number, but silently throws at runtime
const parsePort = (input: string): number => {
const port = Number(input)
if (!Number.isInteger(port) || port <= 0) {
throw new Error(`Invalid port ${input}`) // invisible to the type checker!
}
return port
}Resultar moves expected failures into the return type, composes them linearly with yield*, and enforces exhaustive handling at your application boundary:
import { createTaggedError, ok, Result, ResultTask } from 'resultar'
// 1. Define real Error subclasses with typed $prop interpolation, causes, and redaction
class InvalidEmailError extends createTaggedError({
name: 'InvalidEmailError',
message: 'Invalid email $email',
}) {}
class AccountExistsError extends createTaggedError({
name: 'AccountExistsError',
message: 'Account $email already exists',
}) {}
class DatabaseError extends createTaggedError({
name: 'DatabaseError',
message: 'Database $operation failed',
}) {}
// 2. Write pure domain functions with explicit Result<T, E> contracts
const normalizeEmail = (raw: string): Result<string, InvalidEmailError> => {
const email = raw.trim().toLowerCase()
return email.includes('@') ? ok(email) : InvalidEmailError.err({ email })
}
// 3. Compose sync + async steps linearly — TypeScript infers the exact error union
const registerAccount = (input: string) =>
ResultTask.gen(function* () {
const email = yield* normalizeEmail(input)
if (email === 'taken@example.com') {
return yield* AccountExistsError.err({ email })
}
return yield* ResultTask.tryPromise({
try: () => db.accounts.insert({ email }),
catch: (cause) => new DatabaseError({ cause, operation: 'insert-account' }),
})
})
// Inferred: ResultTask<Account, InvalidEmailError | AccountExistsError | DatabaseError>
// 4. Handle every failure exhaustively at the boundary (compile error if a tag is missed!)
const result = await registerAccount(req.body.email).runResult()
const response = result.matchTags(
(account) => ({ status: 201, body: account }),
{
InvalidEmailError: (err) => ({ status: 400, body: { code: err._tag, message: err.message } }),
AccountExistsError: (err) => ({ status: 409, body: { code: err._tag, message: err.message } }),
DatabaseError: (err) => ({ status: 503, body: { code: err._tag, message: err.message } }),
},
)pnpm add resultar
# Optional native TypeScript-Go compiler + Resultar linter & LSP:
pnpm add -D resultar-checkAlso published on JSR as @inaiat/resultar.
Resultar began as a fork of neverthrow and evolved to occupy the practical middle ground between lightweight Result boxes and full functional runtimes like Effect.
| Capability | neverthrow |
Resultar |
Effect |
|---|---|---|---|
| Entry cost | 1 function (Result<T, E>) |
1 function (Result<T, E>) |
Full Effect<A, E, R> ecosystem |
| Async execution boundary | await ResultAsync<T, E> |
Explicit Task runners (task.runResult() returns ResultAsync<T, E>) |
Effect.runPromise / runtime |
Tagged Error classes & secret redaction |
Manual | Built-in (createTaggedError, redact) |
Built-in (Data.TaggedError, Redacted) |
| Retries, timeouts, races & virtual clock | Manual | Built-in (Retry.policy, timeout, Clock.virtual) |
Built-in (Schedule, TestClock) |
Typed DI (R), LIFO resource scopes & fibers |
No | Opt-in via ResultTask<T, E, R> & resultar-di |
Always present (Effect<A, E, R>, Layer, Scope) |
Native compiler enforcement (noDiscard) |
ESLint plugin | Native TypeScript-Go CLI & LSP (resultar-check) |
Language service plugin |
Start with synchronous Result values and add ResultTask for asynchronous workflows. Executing a task with task.runResult() returns ResultAsync<T, E> — an in-flight Result (PromiseLike<Result<T, E>>) that can be awaited directly or transformed and folded with the same methods as Result:
flowchart LR
R["<b>Stage 1: Result<T, E></b><br/>Synchronous value (Ok / Err)<br/>Zero async overhead"]
RT["<b>Stage 2: ResultTask<T, E></b><br/>Lazy async workflow<br/>Explicit run boundary"]
RTR["<b>Stage 3: ResultTask<T, E, R></b><br/>Optional service requirements<br/>and resource scopes (R)"]
RA["<b>ResultAsync<T, E></b><br/>In-flight Result (PromiseLike)<br/>Await or chain like Result"]
R -->|"async I/O, retry, timeout"| RT
RT -->|"add services or resource ownership"| RTR
RT -->|"task.runResult()"| RA
RA -->|"fromResultAsync(factory)"| RT
- Stage 1 —
Result<T, E>: Use in pure parsing, validation, and domain functions. - Stage 2 —
ResultTask<T, E>: Use for new async operations, retries, and timeouts. Build work lazily and execute it withrunResult,runExit,runPromise, orrunFork. - Stage 3 —
ResultTask<T, E, R>: Add typed service dependencies or owned resources when needed. Providing requirements leaves a Task that still needs an explicit runner. - In-flight Result —
ResultAsync<T, E>: Returned bytask.runResult()/ResultTask.runResult(task)(and in-flight constructorsokAsync,errAsync,unitAsync,fromPromise,fromSafePromise,tryResultAsync). Await it directly forResult<T, E>or chain.map(),.andThen(),.catchTag(),.match(),.unwrapOr(), and.unwrapOrThrow(). EagerResultAsyncorchestration helpers remain deprecated for existing API compatibility; see the migration table.
Prefer task.runResult(), task.runPromise(), task.runExit(), and task.runFork() in new code.
The static forms, such as ResultTask.runResult(task, options), remain equivalent and supported.
Both forms enforce service requirements and accept the same execution options. runResult returns a ResultAsync
that resolves typed errors as Err (and rejects defects, interruption, and composite causes); runPromise resolves
the value or rejects, runExit preserves every outcome as a value, and runFork starts a root fiber
immediately. Each call starts a new execution. See the runner reference.
import { createTaggedError, ok, Result, ResultTask, Retry } from 'resultar'
class InvalidIdError extends createTaggedError({ name: 'InvalidIdError', message: 'Invalid id $id' }) {}
class UserNotFoundError extends createTaggedError({ name: 'UserNotFoundError', message: 'User $id not found' }) {}
// Stage 1: Synchronous Result<T, E>
const parseUserId = (raw: string): Result<string, InvalidIdError> =>
raw.startsWith('usr_') ? ok(raw) : InvalidIdError.err({ id: raw })
// Stage 2: Requirement-free ResultTask<T, E>, executed at an explicit boundary
const fetchProfile = (id: string) =>
ResultTask.tryPromise({
try: (signal) => fetch(`https://api.example.com/users/${id}`, { signal }).then((r) => r.json()),
catch: (cause) => new UserNotFoundError({ id, cause }),
}).retry(Retry.policy({ times: 2, delayMs: 50 }))
// Stage 3: Add typed service requirements and tagged recovery to the same Task model
const AuditLog = ResultTask.service<{ record(id: string): ResultTask<void> }>()('AuditLog')
const loadAndAuditUser = ResultTask.fn(
function* (rawId: string) {
const id = yield* parseUserId(rawId) // Stage 1 yielded directly
const user = yield* fetchProfile(id) // Stage 2 yielded directly
const audit = yield* AuditLog // Stage 3 adds R = Service<'AuditLog', ...>
yield* audit.record(id)
return user
},
ResultTask.catchTag('UserNotFoundError', (err) => ok({ id: err.id, guest: true })),
)
// Inferred: (rawId: string) => ResultTask<User | Guest, InvalidIdError, Service<'AuditLog', ...>>Resultar's runtime is engineered so that gradual adoption is also pay-as-you-go at runtime:
- Zero-microtask synchronous fast path:
Result.gen, synchronousResultTaskpipelines, andResultTask.scopedworkflows without async steps execute inside a tight synchronouswhileloop (runTaskLoop/#runGen) without scheduling microtasks. On the happy path, the engine reuses static singletons (sharedSuccessExit,idleTaskIterator,idleRunContext) until it encounters a realPromiseor an explicityieldEverycheckpoint. - Services and owned fibers are optional: Ordinary tasks can run with
R = never; introduceservice(),acquireRelease(), andforkChild()when the workflow needs them. - Bounded concurrency by construction: Collection operators like
ResultTask.forEach(items, fn, { concurrency: 8 })use a fixed worker-loop pool without per-item fiber scheduling overhead.
See
benchmarks/README.mdfor reproducible benchmarks comparing Resultar withneverthrowandeffect, along with the methodology and trade-offs.
createTaggedError creates real Error subclasses (instanceof Error === true) with a literal _tag, $variable message interpolation, stable log .fingerprint strings, .findCause(ErrorClass) traversal, and first-class secret redaction:
import { createTaggedError, isRedacted, redact, revealRedacted } from 'resultar'
class TokenRejectedError extends createTaggedError({
name: 'TokenRejectedError',
message: 'Token $token rejected for tenant $tenantId',
}).withProps<{ token: unknown; tenantId: string }>() {}
const err = new TokenRejectedError({
token: redact('sk_live_987654321', 'stripe-key'),
tenantId: 't_42',
cause: new Error('expired signature'),
})
err.message // 'Token <redacted:stripe-key> rejected for tenant t_42'
err.fingerprint // 'TokenRejectedError:Token $token rejected for tenant $tenantId'
err.toJSON() // safe for structured loggers — redacted value is never serialized
if (isRedacted(err.token)) {
revealRedacted(err.token) // 'sk_live_987654321' (explicit unwrap when needed)
}Tie connections, file handles, or transactions to a scope with ResultTask.acquireRelease or ResultTask.acquireDisposable. Finalizers always run in reverse acquisition order (LIFO)—even on typed failure, unexpected defect, timeout, or fiber interruption:
import { Clock, Fiber, ResultTask } from 'resultar'
const withDatabase = ResultTask.acquireRelease({
acquire: ResultTask.tryPromise({
try: (signal) => pool.connect(signal),
catch: (cause) => new ConnectionError({ cause }),
}),
release: (client, exit) =>
ResultTask.sync(() => client.release(exit._tag === 'Failure')),
})
const program = ResultTask.scoped(
ResultTask.gen(function* () {
const client = yield* withDatabase
// Fork a scope-owned background fiber that is automatically interrupted if parent fails
const metricsFiber = yield* ResultTask.forkChild(
ResultTask.sync(() => recordQueryStart()).delay(100),
)
const rows = yield* queryUsers(client).timeout(1_500, () => new QueryTimeoutError())
yield* Fiber.join(metricsFiber)
return rows
}),
)
// Test timeouts and retries deterministically in 0ms of wall-clock time:
const clock = Clock.virtual()
const pending = program.runResult({ clock })
await clock.advanceBy(1_500)
const result = await pendingMatch provides type-narrowing pattern matching over tagged unions, custom discriminators, literals, predicates, and HTTP route contexts. Pure tag tables compile down to
import { Match } from 'resultar'
type BillingEvent =
| { _tag: 'InvoicePaid'; amountCents: number }
| { _tag: 'InvoiceFailed'; reason: string }
const formatEvent = Match.type<BillingEvent>().tagsExhaustive({
InvoicePaid: (e) => `Paid $${(e.amountCents / 100).toFixed(2)}`,
InvoiceFailed: (e) => `Failed: ${e.reason}`,
})A Result only protects your application if callers actually handle it. resultar-check is a native TypeScript-Go CLI and LSP server that runs full TypeScript typechecking and Resultar-specific static analysis in a single native pass:
pnpm add -D resultar-check{
"$schema": "./node_modules/resultar-check/schema.json",
"compilerOptions": {
"plugins": [
{
"name": "resultar-check",
"noDiscard": "error",
"resultTaskRequirementsSafety": "error",
"matchExhaustive": "error",
"taggedErrorPropsMismatch": "error",
"noThrow": "warn",
"noTryCatch": "warn"
}
]
}
}- Catches silent bugs at compile time: Forbids ignored
Result/ResultAsync/ResultTaskreturn values (noDiscard),any-typed service requirements leaking intoResultTask(resultTaskRequirementsSafety), non-exhaustiveMatchchains (matchExhaustive), and invalidcreateTaggedErrorprop types. - CI & Editor ready: Emits human, JSONL, SARIF 2.1.0, and JUnit formats, plus a fast
resultar-check lspserver with hover inspection, quick fixes, andoverview/quickfixesCLI inventories.
The Resultar monorepo ships focused, zero-bloat companion packages for DI, HTTP clients, schema validation, and web frameworks:
| Package | Description | Docs |
|---|---|---|
resultar |
Core Result<T, E>, ResultTask<T, E, R>, ResultAsync<T, E>, Match, Cause, Retry, Clock, Fiber, Semaphore. |
README · Guide |
resultar-check |
Native TypeScript-Go compiler + Resultar diagnostics CLI & LSP server. | README |
resultar-di |
Typed dependency injection, class/function services, request scopes, and test overrides for ResultTask. |
README |
resultar-hono |
Typed Hono middleware & bindings with one DI resource scope per HTTP response and graceful shutdown. | README |
resultar-fastify |
Native Fastify plugin providing application & request-scoped DI services, cancellation, and cleanup. | README |
resultar-nestjs |
Native NestJS module & @Services() decorator providing application & request-scoped DI services, cancellation, and cleanup. |
README |
resultar-request |
Fetch-first JSON HTTP client preserving its eager ResultAsync return contract; bridge into Task workflows. |
README |
resultar-request-zod |
Zod schema validation adapter for resultar-request. |
README |
resultar-request-typebox |
TypeBox schema validation adapter for resultar-request. |
README |
Define services once with resultar-di and expose them to Hono, Fastify, or NestJS routes with automatic per-request resource cleanup and inferred c.env / request.services / @Services() types:
import { createTaggedError, ResultTask } from 'resultar'
import { createHonoApp, createModule, Service } from 'resultar-hono'
class UserNotFoundError extends createTaggedError({
name: 'UserNotFoundError',
message: 'User $id was not found',
}) {}
class Users extends Service('users', {
make: () => ({
findById: (id: string) =>
id === '1' ? ResultTask.ok({ id, name: 'Ada Lovelace' }) : UserNotFoundError.taskErr({ id }),
}),
}) {}
const services = createModule().scoped(Users)
export const app = createHonoApp({ services }, (router) => {
router.get('/users/:id', async (c) => {
const result = await c.env.users.findById(c.req.param('id')).runResult()
return result.matchTags((user) => c.json(user, 200), {
UserNotFoundError: (err) => c.json({ error: err.message }, 404),
})
})
})Every release of resultar bundles version-matched coding-agent instructions directly inside node_modules/resultar/dist/agent/:
node_modules/resultar/dist/agent/SKILL.md: Concise rules and idioms for Claude Code, Cursor, Codex, and Antigravity.node_modules/resultar/dist/llms.txt: Structured index of the public surface and reference guides.- Executable agent evaluation suite: Run
pnpm test:agentsin this workspace to verify coding-agent contracts and grading harnesses (seeskills/resultar/evals/README.md).
Explore complete, runnable projects in examples/:
| Example | Stack | What it demonstrates |
|---|---|---|
examples/cookbook |
Core resultar cookbook |
Sync validation, Result.gen, tagged errors, ResultTask resilience, fibers, and resource cleanup (pnpm run example:cookbook) |
examples/check |
resultar-check |
Every compiler rule finding plus a zero-diagnostic clean reference project |
examples/request |
resultar-request + Zod / TypeBox |
Fetch-first JSON requests with schema validation, retries, and typed HTTP error handling |
examples/hono |
resultar-hono + resultar-di |
Class services, inferred AppHono bindings, test overrides, and per-response DI scopes |
examples/fastify |
resultar-fastify + resultar-di |
Application/request DI scopes, TypeBox routes, and explicit HTTP boundary mapping |
examples/nestjs |
resultar-nestjs + resultar-di |
Native NestJS controllers, @Services() injection, application/request DI scopes, and explicit HTTP boundary mapping |
| Topic | Guide |
|---|---|
Gradual Adoption (Result → ResultTask; eager compatibility bridges) |
docs/gradual-adoption.md |
| Core Package Quickstart & Cookbook | packages/resultar/README.md |
The Core Model (Ok, Err, ResultTask) |
DOCUMENTATION.md#the-model |
Tagged Errors, .withProps(), & Redaction |
DOCUMENTATION.md#tagged-errors |
Linear Generators (Result.gen, Result.fn, ResultTask.fn) |
DOCUMENTATION.md#result-generators |
Lazy Tasks, Services (R), & Resource Scopes |
DOCUMENTATION.md#lazy-tasks-and-service-requirements |
Structured Concurrency (Fiber, Semaphore, Scheduler) |
DOCUMENTATION.md#structured-task-concurrency-fibers-and-semaphores |
Racing, Timeouts, & Deterministic Clock.virtual |
DOCUMENTATION.md#task-racing-timeouts-timing-and-virtual-clock |
Portable Retry Policies (Retry.policy) |
DOCUMENTATION.md#portable-retry-policies-and-resulttask-retry |
Pattern Matching (Match) |
DOCUMENTATION.md#pattern-matching |
Compiler Diagnostics (resultar-check) |
packages/check/README.md |
Performance Benchmarks (resultar vs effect vs neverthrow) |
benchmarks/README.md |
| v4 Migration & Compatibility Notes | docs/migrations/v4.md |
| Complete Public API Map | DOCUMENTATION.md#api-map |
Requirements: Node.js 24+, pnpm 11+, ESM-only.
pnpm install # Install workspace dependencies
pnpm check:full # Run Biome, typecheck, fallow complexity/dead-code checks, and full test suite
pnpm build # Build all workspace packages
pnpm smoke:package # Verify packed tarballs, exports, and published artifacts
pnpm test:examples # Run smoke tests across all example projects
pnpm test:agents # Validate coding-agent skill contractsReproducible benchmarks live in benchmarks/ (pnpm --filter resultar run bench:effect, bench:neverthrow, bench:task). Releases are managed with Changesets (pnpm changeset).