Package Exports
- resultar
Readme
Resultar
Production-facing error handling for TypeScript, without a runtime.
Full documentation, long-form recipes, and the complete API map live in DOCUMENTATION.md.
New In 3.1
Resultar 3.1 turns common production async policies into typed values instead of rejected promises, hidden timers, or helper code that throws.
ResultAsync.timeout,retry,retryOrElse,race,raceAll, andwithResourcecover timeouts, retry policy, fallback, replica reads, and cleanup while preserving the expected error type.ResultAsync.forEachandResultAsync.validateAllsupport sequential work, bounded{ concurrency }, and explicit unbounded execution for batch processing and validation.AbortError/isAbortErrorand redacted tagged-error props withredact,isRedacted, andrevealRedactedmake cancellation and sensitive error metadata visible at the type boundary.pipelets teams package reusable result combinators without hiding the underlyingResult/ResultAsynctype.
Main Functionalities
Resultar gives you typed Result primitives, real Error-based tagged failures, async control
helpers, sync/async composition, and optional no-discard diagnostics. It is small enough to adopt in
one module and strict enough to use at service, HTTP, job, queue, CLI, and integration boundaries.
Result<T, E>
ResultAsync<T, E>
StrictResult<T, E extends Error>
StrictResultAsync<T, E extends Error>Use it when expected failures should be impossible to miss:
- Function signatures show both the success type and the expected error type.
- Domain errors are real
Errorinstances with stable tags, messages,cause, stack traces, and JSON output. ResultAsynccan bound concurrent work, race replicas, apply typed timeouts, retry transient failures, and pair acquisition with cleanup without adding a runtime scheduler.- Sync and async fallible work compose without
try/catchblocks scattered through application code. - Ignored
Resultvalues can be reported by a type-aware no-discard check.
Resultar began as an initial fork of neverthrow. The v3 line keeps the explicit wrapper model,
then leans into Resultar-specific tagged errors, strict service-boundary types, TypeScript 7-first
diagnostics, and ESM-only packaging.
Install
pnpm add resultarnpm install resultarRequirements
- Node.js 24+
- TypeScript 7 for the canonical Resultar diagnostics workflow
- ESM only
import { createTaggedError, ok } from 'resultar'
import type { StrictResult } from 'resultar'CommonJS require('resultar') is not exported.
Why Teams Use It
This signature hides an expected failure:
const parsePort = (value: string): number => {
const port = Number(value)
if (!Number.isInteger(port) || port <= 0) {
throw new Error(`Invalid port ${value}`)
}
return port
}This signature makes the failure part of the contract:
import { createTaggedError, ok } from 'resultar'
import type { StrictResult } from 'resultar'
class InvalidPortError extends createTaggedError({
name: 'InvalidPortError',
message: 'Invalid port $value',
}) {}
const parsePort = (value: string): StrictResult<number, InvalidPortError> => {
const port = Number(value)
return Number.isInteger(port) && port > 0 ? ok(port) : InvalidPortError.err({ value })
}Callers now know the success type and the expected error type without reading the implementation. That is the main trade: expected failures become visible contracts instead of tribal knowledge.
Resultar is intentionally not an application runtime. It gives you explicit error values that work in normal TypeScript.
What You Get
| Problem | Resultar gives you |
|---|---|
Expected failures are hidden behind throw |
Result<T, E> and ResultAsync<T, E> in function signatures |
| Production errors need stable names and metadata | createTaggedError classes with _tag, template props, cause, and .toJSON() |
Async flows turn into nested try/catch |
tryResultAsync, andThen, asyncAndThen, map, orElse, safeTry |
| Repeated result transforms become noisy | pipe with small reusable combinators |
| Network calls need timeouts, retries, and fallback policy | ResultAsync.timeout, retry, retryOrElse, race, and raceAll |
| Batch work needs backpressure without a framework | ResultAsync.forEach and mapped validateAll with { concurrency } |
| Resourceful work needs cleanup on every path | ResultAsync.withResource and native AsyncIterable<Result<T, E>> recipes |
| Boundary handlers miss cases | matchTags for exhaustive tagged-error handling |
| Results are easy to ignore | resultar-check and the resultar-check TypeScript plugin |
| You do not want another application runtime | A small ESM library around explicit values |
Nested try/catch often masks the error the caller actually needs:
const getPosts = async (url: string) => {
try {
const response = await fetch(url)
if (response.status >= 400) {
throw new HttpClientError(response.status)
}
return response.json() as Promise<Post[]>
} catch (cause) {
throw new InfrastructureError({ cause })
}
}Resultar keeps each failure explicit and typed:
const getPosts = (
url: string,
): StrictResultAsync<Post[], FetchPostsError | HttpClientError | ParsePostsError> =>
tryResultAsync(fetch(url), (cause) => new FetchPostsError({ cause }))
.andThen((response) =>
response.status >= 400 ? HttpClientError.err({ status: response.status }) : ok(response),
)
.andThen((response) =>
tryResultAsync(response.json() as Promise<Post[]>, (cause) => new ParsePostsError({ cause })),
)The same flow can be written linearly with safeTry when the chain gets harder to scan:
const getPostsLinear = (
url: string,
): StrictResultAsync<Post[], FetchPostsError | HttpClientError | ParsePostsError> =>
safeTry(async function* () {
const response = yield* tryResultAsync(
fetch(url),
(cause) => new FetchPostsError({ cause }),
)
if (response.status >= 400) {
return HttpClientError.err({ status: response.status })
}
const posts = yield* tryResultAsync(
response.json() as Promise<Post[]>,
(cause) => new ParsePostsError({ cause }),
)
return ok(posts)
})Async Control Without A Runtime
The 3.1 async helpers cover common production workflows while keeping plain Promise and
AbortSignal-compatible APIs underneath. You choose the policy in the type signature instead of
hiding it in a helper that throws.
Retry transient work and fall back to cache only after the retry policy is exhausted:
const user = ResultAsync.retryOrElse(
(attempt, signal) => fetchUser(id, { attempt, signal }),
{
times: 2,
delayMs: ({ nextAttempt }) => nextAttempt * 100,
jittered: 0.5,
while: (error) => error._tag === 'RateLimitError' || error._tag === 'ServiceUnavailableError',
orElse: () => readCachedUser(id),
},
)jittered randomizes the computed retry delay by a percentage factor to avoid synchronized retries.
For example, delayMs: 100 with jittered: 0.5 waits between 50ms and 150ms; omit it or use
0 to keep the exact delay.
Race a primary and replica with cooperative loser abort:
const user = ResultAsync.race(
(signal) => readUserFromPrimary(id, { signal }),
(signal) => readUserFromReplica(id, { signal }),
)Keep timeout as a typed Err, not a rejected promise:
const user = ResultAsync.timeout(
(signal) => readUserFromPrimary(id, { signal }),
{
timeoutMs: 1_500,
onTimeout: () => new FetchUserTimeoutError({ id, timeoutMs: 1_500 }),
},
)Process batches with bounded concurrency and explicit cleanup:
const imported = ResultAsync.withResource({
acquire: (signal) => openImportSession({ signal }),
use: (session, signal) =>
ResultAsync.forEach(
rows,
(row) => importRow(session, row, { signal }),
{ concurrency: 8, discard: true },
),
release: (session) => session.close(),
})Quick Start
import { createTaggedError, ok } from 'resultar'
import type { StrictResult } from 'resultar'
class InvalidEmailError extends createTaggedError({
name: 'InvalidEmailError',
message: 'Invalid email $email',
}) {}
class UserAlreadyExistsError extends createTaggedError({
name: 'UserAlreadyExistsError',
message: 'User $email already exists',
}) {}
type User = {
readonly email: string
readonly id: string
}
const validateEmail = (email: string): StrictResult<string, InvalidEmailError> =>
email.includes('@') ? ok(email) : InvalidEmailError.err({ email })
const ensureUserDoesNotExist = (
email: string,
): StrictResult<string, UserAlreadyExistsError> =>
email === 'taken@example.com' ? UserAlreadyExistsError.err({ email }) : ok(email)
const insertUser = (email: string): StrictResult<User, never> =>
ok({ email, id: 'usr_123' })
const createUser = (
email: string,
): StrictResult<User, InvalidEmailError | UserAlreadyExistsError> =>
validateEmail(email).andThen(ensureUserDoesNotExist).andThen(insertUser)
const response = createUser('taken@example.com').matchTags(
(user) => ({ body: user, statusCode: 201 }),
{
InvalidEmailError: (error) => ({
body: { code: error._tag, message: error.message },
statusCode: 400,
}),
UserAlreadyExistsError: (error) => ({
body: { code: error._tag, message: error.message },
statusCode: 409,
}),
},
)Core Model
Result<T, E> is a synchronous fallible value. It is either an Ok<T> or an Err<E>.
const result = parsePort('3000')
if (result.isOk()) {
result.value
}
if (result.isErr()) {
result.error
}ResultAsync<T, E> is the async version. It wraps promise-based fallible work while keeping the
same composition API.
import { createTaggedError, fromPromise, ok, tryResultAsync } from 'resultar'
import type { StrictResult, StrictResultAsync } from 'resultar'
type User = {
readonly id: string
}
class FetchUserError extends createTaggedError({
name: 'FetchUserError',
message: 'Failed to fetch user $id',
}) {}
class UserResponseError extends createTaggedError({
name: 'UserResponseError',
message: 'User request $id failed with status $status',
}) {}
class ParseUserError extends createTaggedError({
name: 'ParseUserError',
message: 'Failed to parse user $id response',
}) {}
class DecodeUserError extends createTaggedError({
name: 'DecodeUserError',
message: 'Invalid user $id response payload',
}) {}
const decodeUser = (input: unknown, id: string): StrictResult<User, DecodeUserError> => {
if (
typeof input === 'object' &&
input !== null &&
'id' in input &&
typeof input.id === 'string'
) {
return ok({ id: input.id })
}
return DecodeUserError.err({ id })
}
const fetchUser = (
id: string,
): StrictResultAsync<User, FetchUserError | UserResponseError | ParseUserError | DecodeUserError> =>
fromPromise(fetch(`https://example.com/users/${id}`), (cause) => new FetchUserError({ cause, id }))
.andThen((response) =>
response.ok ? ok(response) : UserResponseError.err({ id, status: response.status }),
)
.andThen((response) =>
tryResultAsync<unknown, ParseUserError>(
() => response.json(),
(cause) => new ParseUserError({ cause, id }),
),
)
.andThen((input) => decodeUser(input, id))
const label = await fetchUser('usr_123').match(
(user) => `User ${user.id}`,
(error) => error.message,
)This keeps network failures, non-2xx responses, JSON parse failures, and payload decode failures
separate. In production, treat parsing bytes into unknown and decoding unknown into a domain
type as different failure modes.
Use generic Result<T, E> for local flows where strings, enums, or small domain objects are useful
as E. Prefer StrictResult<T, E extends Error> and
StrictResultAsync<T, E extends Error> at service, HTTP, job, queue, CLI, and integration
boundaries.
Tagged Errors
createTaggedError creates real Error subclasses with stable tags and typed message-template
props.
import { createTaggedError } from 'resultar'
class UserNotFoundError extends createTaggedError({
name: 'UserNotFoundError',
message: 'User $id not found in $source',
}) {}
const error = new UserNotFoundError({
id: 'usr_123',
source: 'database',
})
error instanceof Error // true
error instanceof UserNotFoundError // true
error._tag // 'UserNotFoundError'
error.message // 'User usr_123 not found in database'
error.id // string | number
error.source // string | numberTagged errors include:
_tagmessageTemplatefingerprintcause.toJSON().findCause(ErrorClass)- static
.is(value)for nominal checks - static
.err(props)for returningErr
Template variables are inferred from $variables in the message. Missing variables are TypeScript
errors.
new UserNotFoundError({ id: 'usr_123', source: 'database' })
// @ts-expect-error source is required
new UserNotFoundError({ id: 'usr_123' })Use TaggedEnum only for lightweight tagged unions that do not need to be real errors.
import { taggedEnum } from 'resultar'
import type { TaggedEnum } from 'resultar'
type PaymentError = TaggedEnum<{
CardDeclined: { readonly code: string }
InsufficientFunds: { readonly balance: number }
}>
const PaymentError = taggedEnum<{
CardDeclined: { readonly code: string }
InsufficientFunds: { readonly balance: number }
}>()
const declined = PaymentError.CardDeclined({ code: 'card_declined' })
PaymentError.$is('CardDeclined', declined) // true
PaymentError.$match(declined, {
CardDeclined: (error) => error.code,
InsufficientFunds: (error) => String(error.balance),
})Use redact(value, label?) for tagged error props that must not leak through messages or JSON.
Use revealRedacted(value) only at the exact boundary that is allowed to see the secret.
Composing Results
Use map for infallible transforms:
const normalized = validateEmail('PERSON@EXAMPLE.COM').map((email) => email.toLowerCase())Use as when success matters but the previous value does not:
const created = insertUser(user).as({ status: 'created' as const })Use andThen when the next step can fail:
const user = validateEmail(email).andThen(ensureUserDoesNotExist).andThen(insertUser)Use asyncAndThen when a sync result continues into async work:
const user = validateEmail(email).asyncAndThen(ensureUserDoesNotExistAsync).andThen(insertUserAsync)Use filterOrElse when an Ok value must satisfy another predicate:
class InvalidDomainError extends createTaggedError({
name: 'InvalidDomainError',
message: 'Invalid email domain $domain',
}) {}
const validateCompanyEmail = (
email: string,
): StrictResult<string, InvalidEmailError | InvalidDomainError> =>
validateEmail(email).filterOrElse(
(validEmail) => validEmail.endsWith('@company.com'),
(validEmail) => new InvalidDomainError({ domain: validEmail.split('@')[1] ?? 'unknown' }),
)Use pipe when a transform should be named and reused. A pipe step receives the current
Result/ResultAsync and returns the next value in the chain:
import type { Result, StrictResult } from 'resultar'
const normalizeEmail = <E>(result: Result<string, E>): Result<string, E> =>
result.map((email) => email.trim().toLowerCase())
const requireCompanyDomain = (
result: Result<string, InvalidEmailError>,
): StrictResult<string, InvalidEmailError | InvalidDomainError> =>
result.filterOrElse(
(email) => email.endsWith('@company.com'),
(email) => new InvalidDomainError({ domain: email.split('@')[1] ?? 'unknown' }),
)
const email = validateEmail(input).pipe(normalizeEmail, requireCompanyDomain)The same pattern works for async results. pipe is just composition; it does not catch thrown
errors from the pipe callbacks:
import type { ResultAsync } from 'resultar'
const auditUser =
<E>(result: ResultAsync<User, E>): ResultAsync<User, E> =>
result.tap((user) => logger.info({ userId: user.id }, 'user created'))
const createdEmail = validateEmail(input)
.asyncAndThen(ensureUserDoesNotExistAsync)
.andThen(insertUserAsync)
.pipe(auditUser, (result) => result.map((user) => user.email))Use catchTag or catchTags for local recovery from tagged errors:
const draftUser = createUser('bad-email').catchTag('InvalidEmailError', (error) =>
ok({
email: error.email,
id: 'draft_user',
}),
)Reasoned Tagged Errors
Use catchReason, catchReasons, and unwrapReason when a stable parent error has a nested tagged
reason. This keeps the outer error useful for logs and boundaries while allowing local recovery by
the precise reason.
import { createTaggedError, err, ok, taggedEnum } from 'resultar'
import type { StrictResult, TaggedEnum } from 'resultar'
const AiReason = taggedEnum<{
QuotaExceededError: { readonly limit: number }
RateLimitError: { readonly retryAfterMs: number }
}>()
type AiReason = TaggedEnum<{
QuotaExceededError: { readonly limit: number }
RateLimitError: { readonly retryAfterMs: number }
}>
class AiError extends createTaggedError({
name: 'AiError',
message: 'AI request failed',
}) {
public readonly reason: AiReason
public constructor(props: { readonly cause?: unknown; readonly reason: AiReason }) {
super({ cause: props.cause })
this.reason = props.reason
}
}
const askAi = (): StrictResult<string, AiError> =>
err(new AiError({ reason: AiReason.RateLimitError({ retryAfterMs: 1_000 }) }))const recovered = askAi().catchReason('AiError', 'RateLimitError', (reason) =>
ok(`retry after ${reason.retryAfterMs}ms`),
)
const unwrapped = askAi().unwrapReason('AiError')catchReason removes only the handled reason from the remaining error type. unwrapReason moves the
reason union into the error channel when the parent tag matches.
Use match, matchTags, or matchTagsPartial at boundaries where a result becomes a response,
log entry, queue acknowledgement, CLI exit code, or UI state.
const response = createUser(input.email).matchTagsPartial(
(user) => ({ body: user, statusCode: 201 }),
{
InvalidEmailError: (error) => ({
body: { code: error._tag, message: error.message },
statusCode: 400,
}),
},
(error) => ({
body: { code: 'UnhandledError', message: error.message },
statusCode: 500,
}),
)Fallbacks
Use fallback APIs when an expected failure has a useful recovery path.
| Fallback idea | Resultar API |
|---|---|
| try another branch | result.orElse(() => fallbackResult) |
| replace the failure | mapErr(...) or orElse(() => err(newError)) |
| replace the failure with success | orElse(() => ok(defaultValue)) inside a pipeline |
| default at the edge | unwrapOr(defaultValue) |
| try ordered candidates | Result.firstSuccessOf or ResultAsync.firstSuccessOf |
Use orElse for pipeline recovery:
const user = readUserFromCache(id).orElse(() => readUserFromRemote(id))Use mapErr when a low-level failure should become a domain failure:
const user = readUserFromRemote(id).mapErr(
(cause) => new ReadUserError({ cause, id }),
)Use orElse(() => ok(defaultValue)) when fallback success should stay inside the pipeline:
const user = readUser(id).orElse(() =>
ok({
email: 'guest@example.com',
id: 'guest',
}),
)Use firstSuccessOf when each fallback should run only if earlier candidates fail:
const user = ResultAsync.firstSuccessOf([
() => readUserFromCache(id),
() => readUserFromPrimary(id),
() => readUserFromReplica(id),
])Use combine with records when independent results should keep names:
const context = Result.combine({
org: readOrg(orgId),
user: readUser(userId),
})
const validated = Result.combineWithAllErrors({
email: validateEmail(input.email),
name: validateName(input.name),
})For lazy async traversal, ResultAsync.forEach and mapped ResultAsync.validateAll run
sequentially by default. Pass { concurrency: n } for bounded concurrency or
{ concurrency: "unbounded" } to start every mapped task immediately. See
DOCUMENTATION.md for async concurrency details
and the runtime features Resultar intentionally keeps out of core.
For concurrent async races, use lazy signal-aware tasks. race and raceAll return the first
success. raceFirst returns the first completed result, success or failure. timeout is a
raceFirst convenience.
const user = ResultAsync.timeout(
(signal) => fetchUser(id, { signal }),
{
timeoutMs: 2_000,
onTimeout: () => new FetchUserTimeoutError({ id }),
},
)For transient async failures, use lazy retry tasks. The task receives a zero-based attempt number and
a ResultAsyncAbortSignal-compatible signal; times is the number of retries after the first
attempt.
const user = ResultAsync.retry(
(attempt, signal) => fetchUser(id, { attempt, signal }),
{
times: 2,
delayMs: ({ nextAttempt }) => nextAttempt * 100,
jittered: 0.2,
while: (error) => error._tag === 'RateLimitError',
},
)
const withFallback = ResultAsync.retryOrElse(
(attempt, signal) => fetchUser(id, { attempt, signal }),
{
times: 2,
orElse: () => readCachedUser(id),
},
)Use jittered when many callers may retry at the same time. The value is a delay spread factor:
jittered: 0.2 applies a random delay from 80% to 120% of the computed delayMs.
For resourceful async work, use ResultAsync.withResource when acquisition and cleanup must stay
paired. Cleanup is best-effort and always runs after successful acquisition, including when the use
step returns Err or rejects.
const lines = ResultAsync.withResource({
acquire: (signal) => openFile(path, { signal }),
use: (file, signal) => readLines(file, { signal }),
release: (file) => file.close(),
})For pull-based streams, keep the runtime native: expose an AsyncIterable<Result<T, E>> and close
resources in finally. See
DOCUMENTATION.md for the full recipe.
Use catchTag / catchTags when recovery depends on a specific tagged error. Use
matchTagsPartial when only a boundary needs fallback mapping. Use unwrapOr only at final edges
where defaulting is intentional.
Catching Errors
Resultar has different catch APIs for different error sources. Use the smallest API that matches where the failure appears.
| Need | Use |
|---|---|
| Catch thrown sync code now | tryResult(fn, toError?) |
| Wrap a throwing sync function for later | fromThrowable(fn, toError?) |
| Catch an existing rejecting promise | fromPromise(promise, toError) |
| Catch a promise or async factory | tryResultAsync(promiseOrFactory, toError?) |
| Wrap an async function for later | fromThrowableAsync(fn, toError?) |
| Turn one error into another | mapErr(fn) |
| Recover with another result | orElse(fn) |
| Recover specific tagged errors | catchTag(tag, fn) or catchTags(handlers) |
| Recover nested tagged reasons | catchReason(parentTag, reasonTag, fn) or catchReasons(parentTag, handlers) |
| Convert a result at a boundary | match, matchTags, or matchTagsPartial |
Handle an existing Error value |
matchError or matchErrorPartial |
| Map thrown errors in linear code | safeTry({ try, catch }) |
| Recover after retry exhaustion | ResultAsync.retryOrElse(task, options) |
Catch uncontrolled code at the edge:
const parsed: StrictResult<Config, ParseConfigError> = tryResult(
() => JSON.parse(input) as Config,
(cause) => new ParseConfigError({ cause }),
)
const response: StrictResultAsync<Response, FetchPayloadError> = tryResultAsync(
() => fetch(url),
(cause) => new FetchPayloadError({ cause, url }),
)Thrown or rejected values are unknown at the boundary. The mapper decides the documented error
type: ParseConfigError for parsing and FetchPayloadError for fetching.
Recover inside the pipeline:
const user: StrictResultAsync<User, ReadUserError> = readUserFromCache(id)
.orElse(() => readUserFromDatabase(id))
.mapErr((cause) => new ReadUserError({ cause, id }))The cache/database error union is normalized into one public ReadUserError while preserving the
original error as cause.
Handle tagged errors at the boundary:
const response: HttpResponse = createUser(input).matchTagsPartial(
(user) => ({ body: user, statusCode: 201 }),
{
InvalidEmailError: (error) => ({ body: { code: error._tag }, statusCode: 400 }),
},
(error) => ({ body: { code: error._tag }, statusCode: 500 }),
)For the full decision map, see Catching And Recovering Errors.
Wrapping Throwing Code
Use tryResult and tryResultAsync at the edge of uncontrolled code:
JSON parsing, platform APIs, third-party libraries, I/O, and network calls.
import { tryResult } from 'resultar'
class ParseConfigError extends createTaggedError({
name: 'ParseConfigError',
message: 'Could not parse config',
}) {}
const parseConfig = (input: string): StrictResult<Config, ParseConfigError> =>
tryResult(
() => JSON.parse(input) as Config,
(cause) => new ParseConfigError({ cause }),
)Use fromPromise when you already have a promise and need to map rejection into an expected error:
const user = fromPromise(loadUserFromRemote(id), (cause) => new FetchUserError({ cause, id }))Use tryResultAsync when the async boundary is the thing you want to name and document. The object
form is useful for infrastructure helpers because the task and error mapper stay together:
import { createTaggedError, tryResultAsync } from 'resultar'
import type { StrictResultAsync } from 'resultar'
type AsyncTask = () => Promise<void>
class TaskError extends createTaggedError({
name: 'TaskError',
message: 'Task failed: $label',
}) {}
const tryTask = (label: string, task: AsyncTask): StrictResultAsync<void, TaskError> =>
tryResultAsync({
try: task,
catch: (cause) => new TaskError({ cause, label }),
})After that edge conversion, keep your own domain functions returning Result values instead of
throwing expected failures.
Validation Errors
Keep validation failures as normal tagged errors. Map external validators into a small issue shape at the adapter boundary.
type ValidationIssue = {
readonly message: string
readonly path: string
}
class ValidationError extends createTaggedError({
name: 'ValidationError',
message: 'Invalid input',
}) {
public readonly issues: readonly ValidationIssue[]
public constructor(props: {
readonly cause?: unknown
readonly issues: readonly ValidationIssue[]
}) {
super({ cause: props.cause })
this.issues = props.issues
}
}
const issueFromPath = (path: readonly (number | string)[], message: string): ValidationIssue => ({
message,
path: path.join('.'),
})For Zod, Standard Schema, or any other validator, decode at the edge and map the library-specific
issue format into ValidationIssue[]. Resultar does not depend on any validator package.
Unexpected Errors
Expected application failures belong in Err. Bugs, impossible states, and unrecoverable conditions
should remain normal JavaScript throws or rejected promises.
Use this boundary rule:
- Convert uncontrolled throws and rejections into expected errors with
tryResult,tryResultAsync,fromThrowable,fromThrowableAsync, orfromPromise. - Keep your own recoverable domain failures as
Err. - Use
unwrapOrThrow()only at final edges where turning anErrinto a thrown error is deliberate.
const user = await fetchUser('usr_123').unwrapOrThrow()Inside safeTry, the object form can map thrown bugs from the generator body into an explicit error:
const config = safeTry({
*try() {
const raw = yield* readConfigFile()
return ok(JSON.parse(raw) as Config)
},
catch: (cause) => new ParseConfigError({ cause }),
})Linear Result Code
Use safeTry when a pipeline reads better as linear code. yield* unwraps the Ok value and
short-circuits on the first Err.
import { safeTry } from 'resultar'
const createUser = (
email: string,
): StrictResult<User, InvalidEmailError | UserAlreadyExistsError> =>
safeTry(function* () {
const validEmail = yield* validateEmail(email)
const availableEmail = yield* ensureUserDoesNotExist(validEmail)
return insertUser(availableEmail)
})The same safeTry helper supports async generators:
const createUserAsync = (
email: string,
): StrictResultAsync<User, InvalidEmailError | UserAlreadyExistsError | DatabaseError> =>
safeTry(async function* () {
const validEmail = yield* validateEmail(email)
const availableEmail = yield* ensureUserDoesNotExist(validEmail)
return insertUserAsync(availableEmail)
})The object form can map unexpected thrown errors while leaving yielded Err values unchanged:
const config = safeTry({
*try() {
const raw = yield* readConfigFile()
return ok(JSON.parse(raw) as Config)
},
catch: (cause) => new ParseConfigError({ cause }),
})No-Discard Validation
Resultar values should not be ignored. Use resultar-check to run TypeScript 7 and Resultar
diagnostics through one command.
pnpm add -D resultar-check typescript-7@npm:typescript@rcAdd a check script:
{
"scripts": {
"check": "resultar-check"
}
}resultar-check defaults to tsconfig.json and runs TypeScript with no emit.
These fail in the default must-use mode:
saveUser(input)
const result = saveUser(input)These are intentional:
return saveUser(input)
void saveUser(input)
saveUser(input).match(handleSaved, handleError)The default mode is neverthrow-style must-use: it also reports assigned Result values that are
only passed around and never consumed with match, unwrapOr, _unsafeUnwrap, isOk, isErr,
returned, or explicitly discarded. Use --mode direct for the lower-noise expression-only check.
Configure Resultar rules in tsconfig.json:
{
"$schema": "./node_modules/resultar-check/schema.json",
"compilerOptions": {
"plugins": [{ "name": "resultar-check", "noDiscard": "error" }]
}
}The package-local schema provides editor completion and validation for resultar-check plugin
options.
Oxlint is intentionally not part of the Resultar rules path; use it only for general linting if your project wants it.
For VS Code and Zed setup, use the editor integration section in the resultar-check package guide.
API Decision Guide
| Need | Use |
|---|---|
| Create a sync success | ok(value) |
| Create a sync failure | err(error) or ErrorClass.err(props) |
| Create an async success | okAsync(value) |
| Create an async failure | errAsync(error) |
Create an Ok(undefined) |
unit() or unitAsync() |
| Convert throwing sync code | tryResult or fromThrowable |
| Convert rejecting async code | tryResultAsync, fromPromise, or fromThrowableAsync |
Treat an Err as unrecoverable at an edge |
unwrapOrThrow() |
| Transform success | map or asyncMap |
| Replace success with a known value | as(value) |
| Transform error | mapErr |
| Continue with fallible work | andThen or asyncAndThen |
| Recover from failure | orElse, catchTag, or catchTags |
| Recover from a nested tagged reason | catchReason or catchReasons |
| Move a parent error reason into the error channel | unwrapReason |
| Replace failure with another failure | mapErr or orElse(() => err(newError)) |
| Replace failure with success | orElse(() => ok(defaultValue)) |
| Handle a boundary | match, matchTags, or matchTagsPartial |
| Write linear result code | safeTry |
| Combine two independent results | Result.zip or ResultAsync.zip |
| Combine many independent results | Result.combine or ResultAsync.combine |
| Collect all validation failures | validateAll or combineWithAllErrors |
| Try fallback candidates | firstSuccessOf |
| Bound lazy async traversal | ResultAsync.forEach or mapped ResultAsync.validateAll with { concurrency } |
| Race concurrent tasks | ResultAsync.race, raceAll, raceFirst, or raceWith |
| Apply a cooperative timeout | ResultAsync.timeout |
| Retry transient async work | ResultAsync.retry or ResultAsync.retryOrElse |
| Pair async acquisition with cleanup | ResultAsync.withResource |
| Process collections sequentially | loop, iterate, or forEach |
| Observe without changing outcome | tap, tapError, or log |
| Throw intentionally at a final edge | unwrapOrThrow() |
| Default intentionally at a final edge | unwrapOr(defaultValue) |
What Stays Out Of Core
Resultar keeps the scope narrow: explicit error values, typed expected failures, and composable recovery.
- No generator-based runtime DSL.
- No schedule engine in core.
- No config, cache, request resolver, resource, fiber, or service runtime.
- No raw
Error | Tconversion API.
Version 3 Notes
3.0.0 is a semver-major release:
no-discardtooling moved out of the runtime package and into dedicated packages.TypeScript peer support is now
>=6.0.0.Deprecated APIs are not part of the current API:
Result#finallyResultAsync#finallyResultAsync#safeUnwrapsafeTryAsync
Use log, toDisposable, toAsyncDisposable, and safeTry(async function* () { ... }) instead.
Result#safeUnwrap() remains available for compatibility, but new code should prefer
yield* result.
tryCatch and tryCatchAsync remain exported as backward-compatible aliases. Prefer tryResult
and tryResultAsync in new code and docs.
Documentation
This README is the runtime package entry point. For a full guide with larger examples and the complete API map, see the repository documentation.
More focused material:
- Full guide
- Type-safe error handling article, English
- Artigo sobre tratamento de erros type-safe, Portuguese
- resultar-check package guide
Repository
This repository is a pnpm workspace:
packages/resultar: Resultar runtime package.packages/resultar-check: TypeScript 7 plus Resultar validation.packages/resultar-lint: deprecated compatibility wrapper.packages/resultar-tsgo: deprecated compatibility wrapper for older installs.benchmarks: benchmark package.examples/resultar: runnable core Resultar cookbook.examples/lint: TypeScript 7resultar-checksmoke example.
Common commands:
pnpm install
pnpm run fmt:check
pnpm run check
pnpm test
pnpm run test:cov
pnpm run build
pnpm run check:full
pnpm run test:examples
pnpm run benchLicense
MIT