JSPM

  • Created
  • Published
  • Downloads 478
  • Score
    100M100P100Q97237F
  • License MIT

Result pattern for TypeScript

Package Exports

  • resultar

Readme

Resultar

Production-facing error handling for TypeScript, without a runtime.

Resultar gives you typed Result primitives, real Error-based tagged failures, 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 Error instances with stable tags, messages, cause, stack traces, and JSON output.
  • Sync and async fallible work compose without try/catch blocks scattered through application code.
  • Ignored Result values 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 6 support, and ESM-only packaging.

Install

pnpm add resultar
npm install resultar

Requirements

  • Node.js 24+
  • TypeScript 6+
  • 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
Boundary handlers miss cases matchTags for exhaustive tagged-error handling
Results are easy to ignore resultar-lint check and the resultar-lint 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 })),
    )

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 | number

Tagged errors include:

  • _tag
  • messageTemplate
  • fingerprint
  • cause
  • .toJSON()
  • .findCause(ErrorClass)
  • static .is(value) for nominal checks
  • static .err(props) for returning Err

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 type { TaggedEnum } from 'resultar'

type PaymentError = TaggedEnum<{
  CardDeclined: { readonly code: string }
  InsufficientFunds: { readonly balance: number }
}>

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 catchTag or catchTags for local recovery from tagged errors:

const draftUser = createUser('bad-email').catchTag('InvalidEmailError', (error) =>
  ok({
    email: error.email,
    id: 'draft_user',
  }),
)

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.

Effect fallback idea Resultar equivalent
try another effect 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 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.

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 }))

After that edge conversion, keep your own domain functions returning Result values instead of throwing expected failures.

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, or fromPromise.
  • Keep your own recoverable domain failures as Err.
  • Use unwrapOrThrow() only at final edges where turning an Err into 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. Install resultar-lint to report discarded Result and ResultAsync values.

pnpm add -D resultar-lint typescript

Add a lint-like script:

{
  "scripts": {
    "lint:resultar": "resultar-lint check --project tsconfig.json"
  }
}

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.

For editor diagnostics, enable the TypeScript language-service plugin:

{
  "compilerOptions": {
    "plugins": [{ "name": "resultar-lint", "noDiscard": "error" }]
  }
}

TypeScript language-service plugins are editor-only by default. To make tsc report Resultar diagnostics during builds, patch the local TypeScript installation:

pnpm exec resultar-lint patch
pnpm exec resultar-lint doctor
pnpm exec resultar-lint unpatch

For TypeScript 7 native-preview projects, resultar-tsgo exposes a tsgo wrapper that runs native TypeScript and then Resultar no-discard validation.

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
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
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)

Version 3 Notes

3.0.0 is a semver-major release:

  • no-discard tooling 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#finally

  • ResultAsync#finally

  • ResultAsync#safeUnwrap

  • safeTryAsync

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:

Repository

This repository is a pnpm workspace:

  • packages/resultar: Resultar runtime package.
  • packages/resultar-lint: TypeScript language-service diagnostics and no-discard CLI.
  • packages/resultar-tsgo: TypeScript 7 native-preview tsgo wrapper plus Resultar no-discard validation.
  • benchmarks: benchmark package.
  • examples/resultar-lint: TypeScript 6 no-discard smoke example.
  • examples/tsgo: TypeScript 7 native-preview smoke example.

Common commands:

pnpm install
pnpm run fmt:check
pnpm run lint
pnpm test
pnpm run test:cov
pnpm run build
pnpm run check:full
pnpm run test:language-service
pnpm run bench

License

MIT