resultar
v3.6.0
Published
Result pattern for TypeScript
Maintainers
Readme
Resultar
Typed, composable error handling for TypeScript.
Resultar makes expected failures visible in function signatures instead of hiding them behind
throw, rejected promises, nullable values, or undocumented conventions.
Result<T, E>
ResultAsync<T, E>
ResultTask<T, E, R>Tis the success value.Eis the expected failure.Ris the typed service environment still required by aResultTask.- Callers must handle the result before they can use the value.
Resultar stays focused on explicit values. It does not require a framework, dependency injection container, scheduler, or application runtime.
Highlights
| You need | Resultar gives you |
| --- | --- |
| Expected failures in the type system | Result<T, E> and ResultAsync<T, E> |
| Reusable lazy workflows | ResultTask<T, E, R> with explicit execution boundaries and typed services |
| Production errors with useful metadata | Real Error subclasses from createTaggedError |
| Composable sync and async workflows | map, mapErr, andThen, asyncAndThen, and orElse |
| Linear code without scattered try/catch | safeTry with yield* short-circuiting |
| Explicit production async policy | Typed timeout, retry, race, concurrency, and cleanup helpers |
| Exhaustive application boundaries | matchTags for tagged error unions |
| Guardrails for ignored results | The optional native resultar-check CLI |
Resultar began as a fork of neverthrow. The current API keeps the familiar explicit Result model
and adds Resultar-specific tagged errors, production async helpers, exhaustive tagged boundaries,
and optional TypeScript-backed diagnostics.
Contents
- Install
- Why Resultar
- Quick Start
- Compose Results
- Tagged Errors
- Async Work
- Lazy Workflows With ResultTask
- Production Async Policies
- Linear Workflows With Result.gen
- Recover Inside A Workflow
- Handle Results At Boundaries
- Strict Results At Production Boundaries
- Prevent Ignored Results
- HTTP Request Packages
- API Decision Guide
- More Documentation
Install
pnpm add resultarnpm install resultarRequirements:
- Node.js 24+
- ESM only
- TypeScript 7+ for the canonical
resultar-checkdiagnostics workflow
import { createTaggedError, ok } from 'resultar'
import type { Result } from 'resultar'CommonJS require('resultar') is not exported.
Why Resultar
This signature does not tell callers that parsing can fail:
const parsePort = (input: string): number => {
const port = Number(input)
if (!Number.isInteger(port) || port <= 0) {
throw new Error(`Invalid port ${input}`)
}
return port
}Resultar moves the expected failure into the contract:
import { createTaggedError, ok } from 'resultar'
import type { Result } from 'resultar'
class InvalidPortError extends createTaggedError({
name: 'InvalidPortError',
message: 'Invalid port $input',
}) {}
const parsePort = (input: string): Result<number, InvalidPortError> => {
const port = Number(input)
return Number.isInteger(port) && port > 0 ? ok(port) : InvalidPortError.err({ input })
}The caller can now see both outcomes without reading the implementation:
const port = parsePort(process.env.PORT ?? '3000')
const message = port.match(
(value) => `Listening on ${value}`,
(error) => error.message,
)Use Err for failures the caller can reasonably handle: invalid input, conflicts, missing records,
rate limits, timeouts, and unavailable dependencies. Keep programming bugs and impossible states as
normal JavaScript throws.
Quick Start
The examples below build one account-creation workflow.
Start with real tagged errors. Variables in the message template become required constructor props:
import { createTaggedError, ok } from 'resultar'
import type { Result } from 'resultar'
class InvalidEmailError extends createTaggedError({
name: 'InvalidEmailError',
message: 'Invalid email $email',
}) {}
class AccountAlreadyExistsError extends createTaggedError({
name: 'AccountAlreadyExistsError',
message: 'Account $email already exists',
}) {}
type Account = {
readonly email: string
readonly id: string
}Return Ok for success and Err for an expected failure:
const normalizeEmail = (input: string): Result<string, InvalidEmailError> => {
const email = input.trim().toLowerCase()
return email.includes('@') ? ok(email) : InvalidEmailError.err({ email })
}
const ensureAccountIsAvailable = (
email: string,
): Result<string, AccountAlreadyExistsError> =>
email === '[email protected]' ? AccountAlreadyExistsError.err({ email }) : ok(email)
const buildAccount = (email: string): Result<Account, never> =>
ok({ email, id: 'acct_123' })Compose the steps with andThen. The resulting error type is inferred as a union:
type CreateAccountError = InvalidEmailError | AccountAlreadyExistsError
const createAccount = (input: string): Result<Account, CreateAccountError> =>
normalizeEmail(input).andThen(ensureAccountIsAvailable).andThen(buildAccount)Inspect a result directly when local branching is clearer:
const account = createAccount('[email protected]')
if (account.isOk()) {
account.value.email
}
if (account.isErr()) {
account.error.message
}Or consume both outcomes with match:
const label = createAccount('[email protected]').match(
(created) => `Created ${created.id}`,
(error) => `Could not create account: ${error.message}`,
)Compose Results
Resultar composition keeps success and failure channels separate.
Transform success with map
const accountId = createAccount('[email protected]').map((account) => account.id)Add a fallible condition with filterOrElse
class UnsupportedEmailDomainError extends createTaggedError({
name: 'UnsupportedEmailDomainError',
message: 'Email domain $domain is not supported',
}) {}
const requireCompanyEmail = (
email: string,
): Result<string, InvalidEmailError | UnsupportedEmailDomainError> =>
normalizeEmail(email).filterOrElse(
(normalized) => normalized.endsWith('@company.com'),
(normalized) =>
new UnsupportedEmailDomainError({ domain: normalized.split('@')[1] ?? 'unknown' }),
)Continue with fallible work using andThen
const createCompanyAccount = (input: string) =>
requireCompanyEmail(input).andThen(ensureAccountIsAvailable).andThen(buildAccount)Transform a failure with mapErr
class AccountServiceError extends createTaggedError({
name: 'AccountServiceError',
message: 'Account service failed for $email',
}) {}
const account = createAccount(input).mapErr(
(cause) => new AccountServiceError({ cause, email: input }),
)Recover with orElse
const account = findAccountByEmail(email).orElse(() => createAccount(email))Use pipe when a result transform should be named and reused:
import type { Result } from 'resultar'
const auditAccount = <E>(result: Result<Account, E>): Result<Account, E> =>
result.tap((account) => logger.info({ accountId: account.id }, 'account ready'))
const accountEmail = createAccount(input)
.pipe(auditAccount)
.map((account) => account.email)Callbacks passed to transform methods are normal application code. Resultar does not silently catch
exceptions thrown by map, andThen, or pipe callbacks. Wrap uncontrolled code at the edge
instead.
Tagged Errors
createTaggedError produces real Error subclasses with stable tags and structured metadata:
const error = new AccountAlreadyExistsError({ email: '[email protected]' })
error instanceof Error // true
error instanceof AccountAlreadyExistsError // true
error._tag // 'AccountAlreadyExistsError'
error.message // 'Account [email protected] already exists'
error.email // string | number
error.fingerprint // stable error fingerprint
error.toJSON() // serializable metadataTagged errors support:
_tagfor exhaustive handling;- typed message-template props;
causeand stack traces;fingerprintandmessageTemplate;- cycle-safe
.toJSON()output; .findCause(ErrorClass)for nested causes;- static
.is(value)for nominal checks; - static
.err(props)for returning anErrdirectly.
Preserve the original failure as cause when translating infrastructure errors:
class DatabaseError extends createTaggedError({
name: 'DatabaseError',
message: 'Database $operation failed',
}) {}
const databaseError = new DatabaseError({
cause: new Error('connection refused'),
operation: 'insert-account',
})
databaseError.findCause(Error)Use redact when metadata must remain available to authorized code without leaking through error
messages or JSON:
import { createTaggedError, isRedacted, redact, revealRedacted } from 'resultar'
class TokenRejectedError extends createTaggedError({
name: 'TokenRejectedError',
message: 'Token $token was rejected',
}) {}
const error = new TokenRejectedError({
token: redact('secret-token', 'api-token'),
})
error.message // 'Token <redacted:api-token> was rejected'
if (isRedacted(error.token)) {
revealRedacted(error.token) // 'secret-token'
}Use taggedEnum for lightweight tagged states or nested reasons that do not need to be real
Error instances. See the
tagged enum guide
for the complete API.
Async Work
ResultAsync<T, E> is the async counterpart to Result<T, E>. It is awaitable and exposes the same
composition style.
Map rejections from external code into a documented error type:
import { tryResultAsync } from 'resultar'
import type { ResultAsync } from 'resultar'
class DatabaseError extends createTaggedError({
name: 'DatabaseError',
message: 'Database $operation failed',
}) {}
const persistAccount = (email: string): ResultAsync<Account, DatabaseError> =>
tryResultAsync(
() => accountRepository.insert({ email }),
(cause) => new DatabaseError({ cause, operation: 'insert-account' }),
)Continue from synchronous validation into async work with asyncAndThen:
type CreateAccountAsyncError =
| InvalidEmailError
| AccountAlreadyExistsError
| DatabaseError
const createAccountAsync = (
input: string,
): ResultAsync<Account, CreateAccountAsyncError> =>
normalizeEmail(input).andThen(ensureAccountIsAvailable).asyncAndThen(persistAccount)Once a workflow is already asynchronous, andThen accepts callbacks that return either Result or
ResultAsync:
const provisioned = createAccountAsync(input)
.andThen(assignDefaultPlan)
.andThen(sendWelcomeEmail)
.map((account) => ({ account, status: 'ready' as const }))Choose the wrapper that matches the external boundary:
| External work | Use |
| --- | --- |
| Run throwing synchronous code now | tryResult(fn, toError) |
| Wrap a throwing synchronous function | fromThrowable(fn, toError) |
| Adapt an existing promise | fromPromise(promise, toError) |
| Run a promise or async factory | tryResultAsync(factory, toError) |
| Wrap an async function | fromThrowableAsync(fn, toError) |
| Adapt a callback or subscription | ResultAsync.fromCallback(options) |
Prefer a factory with tryResultAsync when creating the promise can also throw synchronously.
Lazy Workflows With ResultTask
ResultTask<T, E, R> is the lazy workflow primitive in Resultar. Creating one does not start the
work; runResult, runExit, or runPromise executes it explicitly. R records the service tags
still required by the workflow, so the execution boundary can require an explicit environment.
Unlike ResultAsync, a ResultTask is a reusable description of work rather than an already-started
operation. Mapping, chaining, recovery, service provision, and generator composition all remain lazy.
| Need | API |
| --- | --- |
| Create an immediate success or failure | succeed, fail, fromResult |
| Defer synchronous work | sync, try |
| Defer promise-producing work | tryPromise |
| Transform or chain | map, flatMap, andThen |
| Recover typed failures | catchAll |
| Write a linear lazy workflow | gen with yield* |
| Declare and provide dependencies | service, provideService, provideServices |
| Execute at the application boundary | runExit, runResult, runPromise |
import { ResultTask } from 'resultar'
const loadUser = (id: string) =>
ResultTask.tryPromise({
try: (signal) => fetch(`/users/${id}`, { signal }).then((response) => response.json()),
catch: (cause) => new Error(`Could not load user: ${String(cause)}`),
})
const task = loadUser('user_123')
const result = await ResultTask.runResult(task)sync treats a thrown value as an unexpected defect. Use try or tryPromise when the boundary is
expected to throw or reject and should map that cause into E. tryPromise receives the execution
AbortSignal, so callers can cancel cooperative work without starting it early.
Choose the execution boundary based on how much information the application needs:
| Boundary | Result |
| --- | --- |
| runExit(task) | Exit<T, E> preserving Success, typed Fail, and unexpected Die causes |
| runResult(task) | Result<T, E>; a Die rejects instead of entering the typed error channel |
| runPromise(task) | T; typed failures and defects reject for integration with Promise-only APIs |
const controller = new AbortController()
const exit = await ResultTask.runExit(loadUser('user_123'), {
signal: controller.signal,
})
if (exit._tag === 'Failure' && exit.cause._tag === 'Die') {
console.error('Unexpected defect', exit.cause.defect)
}Instance methods and their static functional forms preserve laziness and infer combined error and environment types:
const userName = loadUser('user_123')
.map((user) => String(user.name))
.andThen((name) => ResultTask.succeed(name.trim()))
.catchAll((error) => ResultTask.succeed(`unavailable: ${error.message}`))catchAll recovers only typed failures. Runtime defects remain defects and are visible through
runExit. The equivalent functional forms are ResultTask.map, ResultTask.flatMap, and
ResultTask.catchAll.
Workflows can request typed services with yield* and receive them at the boundary. Pass the service
type and its literal identifier so the named environment remains checked:
const Database = ResultTask.service<
{ findUser: (id: string) => Promise<string> },
'Database'
>('Database')
const taskWithDatabase = ResultTask.gen(function* () {
const database = yield* Database
return yield* ResultTask.tryPromise({
try: () => database.findUser('user_123'),
catch: () => 'database-error' as const,
})
})
const resultWithDatabase = await ResultTask.runResult(taskWithDatabase, {
services: { Database: { findUser: async () => 'Ada' } },
})The environment requirement is part of the task type. A missing or misspelled Database property is
a compile-time error at runResult, runExit, or runPromise. Dependencies can also be bound before
the final boundary:
const database = { findUser: async () => 'Ada' }
const readyWithOne = ResultTask.provideService(taskWithDatabase, Database, database)
const readyWithAll = ResultTask.provideServices(taskWithDatabase, { Database: database })
await ResultTask.runResult(readyWithOne)
await ResultTask.runResult(readyWithAll)ResultTask.gen composes tasks and services linearly. On short-circuit, generator finally blocks
are closed and any yielded cleanup tasks or services are interpreted before execution completes:
const program = ResultTask.gen(function* () {
try {
return yield* loadUser('user_123')
} finally {
yield* ResultTask.sync(() => logger.info('load-user finished'))
}
})If cleanup itself fails or defects, that cleanup exit becomes the final exit. ResultTask 3.6 keeps
execution deliberately small: it provides laziness, typed services, cooperative cancellation, and
explicit exits, but does not yet include a scheduler, scopes, or Fiber runtime.
Production Async Policies
Resultar models common resilience policy in the expected error channel. These helpers use lazy tasks so retries can start fresh work and racing helpers can pass a cooperative abort signal.
Timeout stays typed
class AccountTimeoutError extends createTaggedError({
name: 'AccountTimeoutError',
message: 'Account $accountId timed out after $timeoutMs milliseconds',
}) {}
const account = ResultAsync.timeout(
(signal) => loadAccount(accountId, { signal }),
{
timeoutMs: 1_500,
onTimeout: () => new AccountTimeoutError({ accountId, timeoutMs: 1_500 }),
},
)If the timer wins, the timeout is returned as Err<AccountTimeoutError> and the task receives an
abort signal. No rejected timeout promise is introduced.
Retry transient failures
const account = ResultAsync.retry(
(attempt, signal) => loadAccount(accountId, { attempt, signal }),
{
times: 2,
delayMs: ({ nextAttempt }) => nextAttempt * 100,
jittered: 0.2,
while: (error) => error._tag === 'DatabaseBusyError',
},
)times is the number of retries after the first attempt. Use retryOrElse when retry exhaustion
should continue into an explicit fallback:
const account = ResultAsync.retryOrElse(
(attempt, signal) => loadAccount(accountId, { attempt, signal }),
{
times: 2,
orElse: () => loadCachedAccount(accountId),
},
)Race equivalent providers
const account = ResultAsync.race(
(signal) => loadAccountFromPrimary(accountId, { signal }),
(signal) => loadAccountFromReplica(accountId, { signal }),
)race returns the first success and keeps waiting after an early failure. Use raceFirst when the
first completed success or failure should win, or raceAll for several equivalent providers.
Bound concurrent work
const imported = ResultAsync.forEach(
accountInputs,
(input) => createAccountAsync(input.email),
{ concurrency: 8, discard: true },
)forEach is sequential by default and stops scheduling new work after the first Err. Pass a
positive concurrency number for bounded work or "unbounded" to start every mapped task.
Use mapped validateAll when every independent item should run and all failures should be returned:
const validated = ResultAsync.validateAll(
accountInputs,
(input) => normalizeEmail(input.email).asyncMap(async (email) => email),
{ concurrency: 8 },
)Pair acquisition with cleanup
const account = ResultAsync.withResource({
acquire: (signal) => databasePool.connect({ signal }),
use: (connection, signal) => connection.insertAccount(input, { signal }),
release: (connection) => connection.close(),
})After successful acquisition, release runs when use succeeds, returns Err, or rejects. For
stream-shaped work, use native AsyncIterable<Result<T, E>>; Resultar intentionally does not add a
stream runtime or scheduler.
See the full guides for racing and timeout, retry, concurrency, and resource cleanup.
Linear Workflows With Result.gen
Use Result.gen when a longer chain reads better as linear code. yield* extracts the Ok value and
short-circuits on the first Err. safeTry remains an exact compatibility alias.
import { Result } from 'resultar'
import type { ResultAsync } from 'resultar'
const createAccountLinear = (
input: string,
): ResultAsync<Account, CreateAccountAsyncError> =>
Result.gen(async function* () {
const email = yield* normalizeEmail(input)
yield* ensureAccountIsAvailable(email)
return persistAccount(email)
})The object form can map unexpected throws from inside the generator while leaving yielded Err
values unchanged:
const account = Result.gen({
async *try() {
const email = yield* normalizeEmail(input)
return persistAccount(email)
},
catch: (cause) => new AccountServiceError({ cause, email: input }),
})Prefer yield* result. Compatibility helpers such as safeUnwrap are not needed in new workflows.
Recover Inside A Workflow
Use recovery methods when a failure has a useful local alternative.
Recover from one tagged error with catchTag:
const account = createAccountAsync(input).catchTag(
'AccountAlreadyExistsError',
(error) => findAccountByEmail(String(error.email)),
)Recover several tagged errors with catchTags:
const account = createAccountAsync(input).catchTags({
AccountAlreadyExistsError: (error) => findAccountByEmail(String(error.email)),
InvalidEmailError: (error) =>
ok({ email: String(error.email), id: 'draft_account' }),
})Unhandled tags remain in the error channel. Use mapErr when the failure should be translated and
orElse when recovery should run another Result-producing branch.
const account = loadCachedAccount(accountId)
.orElse(() => loadAccount(accountId))
.mapErr((cause) => new AccountServiceError({ cause, email: input }))Use recovery inside a workflow. At an application boundary, consume the final result with match,
matchTags, or matchTagsPartial.
Handle Results At Boundaries
Boundary code turns a Result into an HTTP response, queue acknowledgement, CLI exit code, log entry, or UI state.
Use matchTags when tagged errors should be handled exhaustively:
const response = await createAccountAsync(input).matchTags(
(account) => ({
body: account,
statusCode: 201,
}),
{
AccountAlreadyExistsError: (error) => ({
body: { code: error._tag, message: error.message },
statusCode: 409,
}),
DatabaseError: (error) => ({
body: { code: error._tag, message: error.message },
statusCode: 503,
}),
InvalidEmailError: (error) => ({
body: { code: error._tag, message: error.message },
statusCode: 400,
}),
},
)If a handler is missing, TypeScript reports it. If only selected errors need special handling, use
matchTagsPartial with a fallback. Use plain match when the distinction between individual error
tags does not matter.
Avoid unwrapping in normal application flows. unwrapOr, unwrapOrThrow, _unsafeUnwrap, and
_unsafeUnwrapErr are best reserved for deliberate final boundaries or tests.
Strict Results At Production Boundaries
Result<T, E> intentionally allows any failure type. Strings, enums, and small objects can be useful
inside narrow local workflows.
For service, HTTP, job, queue, CLI, and integration boundaries, prefer the Error-only aliases:
import type { StrictResult, StrictResultAsync } from 'resultar'
const validateAccount = (
input: string,
): StrictResult<string, InvalidEmailError> => normalizeEmail(input)
const provisionAccount = (
input: string,
): StrictResultAsync<Account, CreateAccountAsyncError> => createAccountAsync(input)StrictResult<T, E extends Error> and StrictResultAsync<T, E extends Error> are type-only aliases.
They keep the same Resultar API while documenting that failures carry standard Error behavior:
messages, causes, stack traces, and structured metadata.
Prevent Ignored Results
Result values are useful only when callers handle them. Install the native resultar-check CLI:
pnpm add -D resultar-checkUse the CLI as the authoritative local and CI check:
{
"scripts": {
"check": "resultar-check"
}
}The CLI uses TypeScript-Go to run compiler and Resultar diagnostics over the same tsconfig.json.
Configure its rules in the project file:
{
"$schema": "./node_modules/resultar-check/schema.json",
"compilerOptions": {
"plugins": [
{
"name": "resultar-check",
"noDiscard": "error"
}
]
}
}The plugins entry is configuration consumed by the native CLI and stdio LSP server; it does not
install an editor extension. Run resultar-check lsp from an editor language-server configuration.
A separate TypeScript installation is not required.
See the
resultar-check guide
for all diagnostics, severities, modes, and ignore patterns.
HTTP Request Packages
Resultar's Fetch-first request packages return ResultAsync values for request creation, network,
HTTP status, JSON parsing, validation, and retry failures.
| Package | Use it when | Guide |
| --- | --- | --- |
| resultar-request | You use a custom validator or decoder | README |
| resultar-request-typebox | Your response contract is a TypeBox schema | README |
| resultar-request-zod | Your response contract is a Zod schema or transform | README |
The adapters delegate transport, JSON parsing, retries, and error mapping to resultar-request and
re-export its public request types. Install only the core request package and schema adapter your
service needs.
API Decision Guide
| Need | Use |
| --- | --- |
| Create success or failure | ok, err, okAsync, errAsync |
| Create an Ok(undefined) | unit, unitAsync |
| Transform success | map, asyncMap, as |
| Add a fallible condition | filterOrElse |
| Transform failure | mapErr |
| Continue fallible work | andThen, asyncAndThen |
| Recover from failure | orElse, catchTag, catchTags |
| Wrap throwing or rejecting code | tryResult, tryResultAsync, fromPromise |
| Write linear Result code | Result.gen (safeTry compatibility alias) |
| Describe reusable lazy work | ResultTask.succeed, sync, try, tryPromise |
| Compose or recover lazy work | ResultTask.map, flatMap, andThen, catchAll, gen |
| Require or bind typed services | ResultTask.service, provideService, provideServices |
| Execute lazy work explicitly | ResultTask.runExit, runResult, runPromise |
| Handle a final boundary | match, matchTags, matchTagsPartial |
| Combine independent results | zip, combine, combineWithAllErrors |
| Try ordered fallback candidates | firstSuccessOf |
| Process async collections | ResultAsync.forEach, ResultAsync.validateAll |
| Race concurrent tasks | ResultAsync.race, raceAll, raceFirst |
| Apply a timeout | ResultAsync.timeout |
| Retry transient work | ResultAsync.retry, retryOrElse |
| Pair acquisition and cleanup | ResultAsync.withResource |
| Observe without changing the result | tap, tapError, log |
| Default deliberately at an edge | unwrapOr |
| Throw deliberately at an edge | unwrapOrThrow |
The complete API map covers overloads, aliases, callback semantics, collection helpers, conditional helpers, tagged reasons, disposable results, and compatibility APIs.
More Documentation
- Full Resultar guide
- ResultTask core RFC
- Runnable core cookbook
- Catching and recovering errors
- Safe Try
- Validation error recipes
- Coming from other error-handling styles
- Public exports and compatibility aliases
- Type-safe error handling article
- Artigo sobre tratamento de erros type-safe
Resultar is also published on JSR as @inaiat/resultar.
License
MIT
