remorses/errore

Errors as values for TypeScript. Fully type safe error handling.

TypeScript

283

112 commits

updated Oct 9, 2026

See the code

README

errore

Type-safe error handling for TypeScript. Return errors instead of throwing them — as a union type (Error | T), not a wrapper. TypeScript's type narrowing does the rest: forget to handle an error and your code won't compile.

Why?

In Go, functions return errors as values instead of throwing exceptions. errore brings the same convention to TypeScript — but instead of a tuple with two separate variables, functions return a single Error | T union. You check instanceof Error instead of err != nil, and TypeScript narrows the type automatically. No wrapper types like Result<T, E>, no monads — just plain unions and instanceof:

// Go-style: errors as values
const user = await getUser(id)
if (user instanceof NotFoundError) {
  console.error('Missing:', user.id)
  return
}
if (user instanceof DbError) {
  console.error('DB failed:', user.reason)
  return
}
console.log(user.username) // user is User, fully narrowed

Install

npm install errore

Agent Skill

errore ships with a skill file that teaches AI coding agents the errore convention. Install it with:

npx -y skills add remorses/errore -g -y

Then add this to your AGENTS.md:

This codebase uses the errore.org convention. ALWAYS read the errore skill before editing any code.

Quick Start

Define typed errors with variable interpolation and return Error or Value directly:

import * as errore from 'errore'

// Define typed errors with $variable interpolation
class NotFoundError extends errore.createTaggedError({
  name: 'NotFoundError',
  message: 'User $id not found',
}) {}

class DbError extends errore.createTaggedError({
  name: 'DbError',
  message: 'Database query failed: $reason',
}) {}

// Function returns Error | Value (no wrapper!)
async function getUser(id: string): Promise<NotFoundError | DbError | User> {
  const result = await db
    .query(id)
    .catch((e) => new DbError({ reason: e.message, cause: e }))

  if (result instanceof Error) return result
  if (!result) return new NotFoundError({ id })

  return result
}

// Caller handles errors explicitly
const user = await getUser('123')

if (user instanceof Error) {
  const message = errore.matchError(user, {
    NotFoundError: (e) => `User ${e.id} not found`,
    DbError: (e) => `Database error: ${e.reason}`,
    Error: (e) => `Unexpected error: ${e.message}`,
  })
  console.log(message)
  return
}

// TypeScript knows: user is User
console.log(user.name)

Example: API Error Handling

A complete example with custom base class and HTTP status codes:

import * as errore from 'errore'

// Base class with shared functionality
class AppError extends Error {
  statusCode: number = 500

  toResponse() {
    return { error: this.message, code: this.statusCode }
  }
}

// Specific errors with status codes and $variable interpolation
class NotFoundError extends errore.createTaggedError({
  name: 'NotFoundError',
  message: '$resource not found',
  extends: AppError,
}) {}

class ValidationError extends errore.createTaggedError({
  name: 'ValidationError',
  message: 'Invalid $field: $reason',
  extends: AppError,
}) {}

class UnauthorizedError extends errore.createTaggedError({
  name: 'UnauthorizedError',

  extends: AppError,
}) {}

// Service function
async function updateUser(
  userId: string,
  data: { email?: string },
): Promise<NotFoundError | ValidationError | UnauthorizedError | User> {
  const session = await getSession()
  if (!session) {
    return new UnauthorizedError({
      message: 'Not logged in. Sign in at /login or run: mycli login',
    })
  }

  const user = await db.users.find(userId)
  if (!user) {
    return new NotFoundError({ resource: `User ${userId}` })
  }

  if (data.email && !isValidEmail(data.email)) {
    return new ValidationError({
      field: 'email',
      reason: 'Invalid email format',
    })
  }

  return db.users.update(userId, data)
}

// API handler
app.post('/users/:id', async (req, res) => {
  const result = await updateUser(req.params.id, req.body)

  if (result instanceof Error) return res.status(result.statusCode).json(result.toResponse())

  return res.json(result)
})

Hint a Solution

User-facing error messages should include how to fix the problem, not only what went wrong. Agents and CLIs that consume your API can then self-correct instead of guessing.

If the user is logged out, include the login URL or CLI command:

class UnauthorizedError extends errore.createTaggedError({
  name: 'UnauthorizedError',
  message: 'Not logged in. Sign in at $loginUrl or run: $loginCommand',
}) {}

if (!session) {
  return new UnauthorizedError({
    loginUrl: 'https://example.com',
    loginCommand: 'mycli login',
  })
}

This applies to HTTP responses, CLI output, and any error a user or agent will read. Internal errors (DB failures, parse errors) can stay technical.

API

createTaggedError

Create typed errors with variable interpolation in the message:

import * as errore from 'errore'

// Variables are extracted from the message and required in constructor
class NotFoundError extends errore.createTaggedError({
  name: 'NotFoundError',
  message: 'User $id not found in $database',
}) {}

const err = new NotFoundError({ id: '123', database: 'users' })
err.message // 'User 123 not found in users'
err.id // '123'
err.database // 'users'
err._tag // 'NotFoundError'

// Error without variables
class EmptyError extends errore.createTaggedError({
  name: 'EmptyError',
  message: 'Something went wrong',
}) {}
new EmptyError() // no args required

// Message omitted — caller provides it at construction time
class GenericError extends errore.createTaggedError({
  name: 'GenericError',
}) {}
new GenericError({ message: 'caller decides the message' })
// fingerprint is stable regardless of what message is passed

// With cause for error chaining
class WrapperError extends errore.createTaggedError({
  name: 'WrapperError',
  message: 'Failed to process $item',
}) {}
new WrapperError({ item: 'data', cause: originalError })

// With custom base class
class AppError extends Error {
  statusCode = 500
}

class HttpError extends errore.createTaggedError({
  name: 'HttpError',
  message: 'HTTP $status error',
  extends: AppError,
}) {}

const err = new HttpError({ status: 404 })
err.statusCode // 500 (inherited from AppError)
err instanceof AppError // true

Reserved variable names: $_tag, $name, $stack, $cause cannot be used in message templates — they conflict with Error internals.

Error Wrapping and Context

Wrap errors with additional context while preserving the original error via cause:

// Wrap with context, preserve original in cause
async function processUser(id: string): Promise<ServiceError | ProcessedUser> {
  const user = await getUser(id) // returns NotFoundError | User

  if (user instanceof Error) return new ServiceError({ id, cause: user })

  return process(user)
}

// Access original error via cause
const result = await processUser('123')
if (result instanceof Error) {
  console.log(result.message) // "Failed to process user 123"

  if (result.cause instanceof NotFoundError) {
    console.log(result.cause.id) // access original error's properties
  }
}

The error definitions:

import * as errore from 'errore'

class NotFoundError extends errore.createTaggedError({
  name: 'NotFoundError',
  message: 'User $id not found',
}) {}

class ServiceError extends errore.createTaggedError({
  name: 'ServiceError',
  message: 'Failed to process user $id',
}) {}

Browser console prints the full cause chain:

ServiceError: Failed to process user 123
    at processUser (app.js:12)
    at main (app.js:20)
Caused by: NotFoundError: User 123 not found
    at getUser (app.js:5)
    at processUser (app.js:8)

findCause

Walk the .cause chain to find an ancestor matching a specific error class. Similar to Go's errors.As — checks the error itself first, then traverses .cause recursively:

import * as errore from 'errore'

class NotFoundError extends errore.createTaggedError({
  name: 'NotFoundError',
  message: 'User $id not found',
}) {}

class ServiceError extends errore.createTaggedError({
  name: 'ServiceError',
  message: 'Failed to process user $id',
}) {}

// Deep chain: ServiceError -> NotFoundError
const notFound = new NotFoundError({ id: '123' })
const service = new ServiceError({ id: '123', cause: notFound })

// Instance method on tagged errors
const found = service.findCause(NotFoundError)
found?.id // '123' — type-safe access

// Standalone function for any Error
const found2 = errore.findCause(service, NotFoundError)
found2?.id // '123'

This solves the problem where result.cause instanceof MyError only checks one level deep. findCause walks the entire chain:

// A -> B -> C chain
const c = new DbError({ reason: 'connection reset' })
const b = new ServiceError({ id: '123', cause: c })
const a = new NotFoundError({ id: '456', cause: b })

// Manual check only finds B
a.cause instanceof DbError // false — only checks one level

// findCause walks the full chain
a.findCause(DbError) // finds C ✓

Returns undefined if no matching ancestor is found. Safe against circular .cause references.

Custom Base Class with extends

Use extends to inherit from a custom base class. The error will pass instanceof for both the base class and the specific error class:

import * as errore from 'errore'

class AppError extends Error {
  statusCode = 500
  toResponse() {
    return { error: this.message, code: this.statusCode }
  }
}

class NotFoundError extends errore.createTaggedError({
  name: 'NotFoundError',
  message: 'Resource $id not found',
  extends: AppError,
}) {
  statusCode = 404
}

const err = new NotFoundError({ id: '123' })
err instanceof NotFoundError // true
err instanceof AppError // true
err instanceof Error // true

err.statusCode // 404
err.toResponse() // { error: 'Resource 123 not found', code: 404 }

Type Guards

Use instanceof checks to narrow union types:

const result: NetworkError | User = await fetchUser(id)

if (result instanceof Error) return result // result is NetworkError
// result is User

Try Functions

Wrap exceptions as error values:

import * as errore from 'errore'

// Sync - wraps exceptions in UnhandledError
const parsed = errore.try(() => JSON.parse(input))

// Sync - with custom error type
const parsed = errore.try(
  () => JSON.parse(input),
  (e) => new ParseError({ reason: e.message, cause: e }),
)

// Async — prefer .catch() for promises (no wrapper needed)
const response = await fetch(url).catch(
  (e) => new NetworkError({ url, cause: e }),
)

// Async — errore.tryAsync also works, but .catch() is preferred
const response = await errore.tryAsync(
  () => fetch(url),
  (e) => new NetworkError({ url, cause: e }),
)

Best practices for try / tryAsync:

  • For async code, prefer .catch() — promise.catch((e) => new MyError({ cause: e })) is simpler and avoids the wrapper. errore.tryAsync still works but .catch() is the idiomatic choice.
  • Use errore.try for sync code — there's no equivalent of .catch() for synchronous throwing calls, so errore.try(() => JSON.parse(input)) is the right tool.
  • Use as low as possible in the call stack — only at boundaries with uncontrolled dependencies (third-party libs, JSON.parse, fetch, file I/O). Your own functions should return errors as values, never throw.
  • Keep the callback minimal — wrap only the single throwing call, not your business logic. The try callback should be a one-liner.
  • Always prefer errore.try over errore.tryFn — they are the same function, but try is the canonical name.

Transformations

Transform and chain operations:

import * as errore from 'errore'

// Transform value (if not error)
const name = errore.map(user, (u) => u.name)

// Transform error
const appError = errore.mapError(dbError, (e) => new AppError({ cause: e }))

// Chain operations
const posts = errore.andThen(user, (u) => fetchPosts(u.id))

// Side effects
const logged = errore.tap(user, (u) => console.log('Got user:', u.name))

Resource Cleanup (defer)

errore ships DisposableStack and AsyncDisposableStack polyfills for Go-like defer cleanup. Works in every runtime — no native DisposableStack support needed:

import * as errore from 'errore'

async function processRequest(id: string): Promise<DbError | Result> {
  // await using = cleanup runs automatically when scope exits
  await using cleanup = new errore.AsyncDisposableStack()

  const db = await connectDb()
  cleanup.defer(() => db.close())

  const cache = await openCache()
  cleanup.defer(() => cache.flush())

  // ... use db and cache ...
  return result
  // cleanup runs in LIFO order: cache.flush() → db.close()
}

Resources are released in reverse order (last deferred = first cleaned up), just like Go's defer. Cleanup runs on normal return, early error return, or thrown exception.

// Sync version with using
function readConfig(path: string): ParseError | Config {
  using cleanup = new errore.DisposableStack()

  const file = openFileSync(path)
  cleanup.defer(() => file.closeSync())

  const lock = acquireLock(path)
  cleanup.defer(() => lock.release())

  return parseConfig(file.readSync())
}

You can also register existing Disposable objects directly:

await using cleanup = new errore.AsyncDisposableStack()
cleanup.use(dbConnection) // calls dbConnection[Symbol.dispose]() on exit
cleanup.adopt(handle, (h) => h.close()) // custom cleanup for non-disposable values

Extraction

Extract values or throw, split arrays by success/error:

import * as errore from 'errore'

// Extract or throw
const user = errore.unwrap(result)
const user = errore.unwrap(result, 'Custom error message')

// Extract or fallback
const name = errore.unwrapOr(result, 'Anonymous')

// Pattern match
const message = errore.match(result, {
  ok: (user) => `Hello, ${user.name}`,
  err: (error) => `Failed: ${error.message}`,
})

// Split array into [successes, errors]
const [users, errors] = errore.partition(results)

Error Matching

Exhaustive pattern matching with matchError. Always assign results to a variable and keep callbacks pure:

import * as errore from 'errore'

class ValidationError extends errore.createTaggedError({
  name: 'ValidationError',
  message: 'Invalid $field',
}) {}

class NetworkError extends errore.createTaggedError({
  name: 'NetworkError',
  message: 'Failed to fetch $url',
}) {}

// Exhaustive matching - Error handler is always required
const message = errore.matchError(error, {
  ValidationError: (e) => `Invalid ${e.field}`,
  NetworkError: (e) => `Failed to fetch ${e.url}`,
  Error: (e) => `Unexpected: ${e.message}`, // required fallback for plain Error
})
console.log(message) // side effects outside callbacks

// Partial matching with fallback
const fallbackMsg = errore.matchErrorPartial(
  error,
  {
    ValidationError: (e) => `Invalid ${e.field}`,
  },
  (e) => `Unknown error: ${e.message}`,
)

// Type guards
ValidationError.is(value) // specific class

How Type Safety Works

TypeScript narrows types after instanceof Error checks:

function example(result: NetworkError | User): string {
  if (result instanceof Error) return result.message // TypeScript: result is NetworkError
  // TypeScript knows: result is User (Error excluded)
  return result.name
}

This works because:

  1. Error is a built-in class TypeScript understands
  2. Custom error classes extend Error
  3. After an instanceof Error check, TS excludes all Error subtypes

Result + Option Combined: Error | T | null

Naturally combine error handling with optional values. No wrapper nesting needed!

import * as errore from 'errore'

class NotFoundError extends errore.createTaggedError({
  name: 'NotFoundError',
  message: 'Resource $id not found',
}) {}

// Result + Option in one natural type
function findUser(id: string): NotFoundError | User | null {
  if (id === 'bad') return new NotFoundError({ id })
  if (id === 'missing') return null
  return { id, name: 'Alice' }
}

const user = findUser('123')

// Handle error first
if (user instanceof Error) return user.message // TypeScript: user is NotFoundError

// Handle null/missing case - use ?. and ?? naturally!
const name = user?.name ?? 'Anonymous'

// Or check explicitly
if (user === null) {
  return 'User not found'
}

// TypeScript knows: user is User
console.log(user.name)

Why this is better than Rust/Zig

LanguageResult + OptionOrder matters?
RustResult<Option<T>, E> or Option<Result<T, E>>Yes, must unwrap in order
Zig!?T (error union + optional)Yes, specific syntax
erroreError | T | nullNo! Check in any order

With errore you check in any order:

  • Use ?. and ?? naturally
  • Check instanceof Error or === null in any order
  • No unwrapping ceremony
  • TypeScript infers everything

Why This Is Better Than Go

Go's error handling uses two separate return values:

user, err := fetchUser(id)
// Oops! Forgot to check err
fmt.Println(user.Name)  // Compiles fine, crashes at runtime

The compiler can't save you here. You can ignore err entirely and use user directly.

With errore, forgetting to check is impossible:

const user = await fetchUser(id) // type: NotFoundError | User

console.log(user.id) // TS Error: Property 'id' does not exist on type 'NotFoundError'

Since errore uses a single union variable instead of two separate values, TypeScript forces you to narrow the type before accessing value-specific properties. You literally cannot use the value without first doing an instanceof Error check.

Note: Properties that exist on both Error and your value type (like name, message) can still be accessed without narrowing. This is a small set of 4 fields: name, message, stack, cause.

The Remaining Gap: Ignored Return Values

There's one case TypeScript alone can't catch — discarded return values:

updateUser(id, data) // returns Error | User, but result is thrown away
await fetchData(url)  // returns Error | Data, silently ignored

The caller forgot to assign the result and check instanceof Error. TypeScript won't complain because expression statements are valid syntax.

lintcn: no-unhandled-error

lintcn is the shadcn for type-aware TypeScript lint rules. You add rules by URL, own the source (Go files in .lintcn/), and customize freely. Rules use the TypeScript type checker — they see resolved types, not just syntax — so they catch things syntax-only linters can't.

lintcn ships a no-unhandled-error rule built specifically for the errore convention. It flags any expression statement where the return type includes Error (or any Error subclass) and the result is discarded:

# Install lintcn
npm install -D lintcn

# Add the no-unhandled-error rule
npx lintcn add https://github.com/remorses/lintcn/tree/main/.lintcn/no_unhandled_error

# Lint your project
npx lintcn lint

What gets flagged:

declare function getUser(id: string): Error | User

getUser("123")          // error: Error-typed return value is not handled
await fetchData("/api") // error: Promise<Error | Data> resolved but not checked
db.query("SELECT 1")   // error: Error | { rows: any[] } discarded

What is NOT flagged:

// Assigned to variable — you'll check it
const user = getUser("123")
if (user instanceof Error) return user

// Explicitly discarded with void
void getUser("123")

// void/undefined/never returns — nothing to handle
console.log("hello")
arr.push(1)

// Return statement — caller handles it
function wrapper() { return getUser("123") }

Because the rule uses the type checker, it only flags calls that return Error-typed unions — zero false positives on void-returning functions like console.log or arr.push.

The rule lives in .lintcn/no_unhandled_error/ — you own the source and can customize it. Combined with errore's instanceof Error narrowing, this closes the last gap: every error must be either handled or explicitly discarded with void.

Comparison with Result Types

Direct returns vs wrapper methods:

Result Patternerrore
Result.ok(value)just return value
Result.err(error)just return error
result.valuedirect access after guard
result.map(fn)map(result, fn)
Result<User, Error>Error | User
Result<Option<T>, E>Error | T | null

Vs neverthrow / better-result

These libraries wrap values in a Result container. You construct results with ok() and err(), then unwrap them with .value and .error:

// neverthrow
import { ok, err, Result } from 'neverthrow'

function getUser(id: string): Result<User, NotFoundError> {
  const user = db.find(id)
  if (!user) return err(new NotFoundError({ id }))
  return ok(user) // must wrap
}

const result = getUser('123')
if (result.isErr()) {
  console.log(result.error) // must unwrap
  return
}
console.log(result.value.name) // must unwrap
// errore
function getUser(id: string): User | NotFoundError {
  const user = db.find(id)
  if (!user) return new NotFoundError({ id })
  return user // just return
}

const user = getUser('123')
if (user instanceof Error) {
  console.log(user) // it's already the error
  return
}
console.log(user.name) // it's already the user

The key insight: T | Error already encodes success/failure. TypeScript's type narrowing does the rest. No wrapper needed.

Featureneverthrowerrore
Type-safe errors✓✓
Exhaustive handling✓✓
Works with nullResult<T | null, E>T | E | null
Learning curveNew API (ok, err, map, andThen, ...)Just instanceof
Bundle size~3KB min~0 bytes
InteropRequires wrapping/unwrapping at boundariesNative TypeScript

neverthrow also requires a separate plugin to catch unhandled results. With errore, TypeScript itself prevents you from using a value without checking the error first.

Vs Effect.ts

Effect is not just error handling—it's a complete functional programming framework with dependency injection, concurrency primitives, resource management, streaming, and more.

// Effect.ts - a paradigm shift
import { Effect, pipe } from 'effect'

const program = pipe(
  fetchUser(id),
  Effect.flatMap((user) => fetchPosts(user.id)),
  Effect.map((posts) => posts.filter((p) => p.published)),
  Effect.catchTag('NotFoundError', () => Effect.succeed([])),
)

const result = await Effect.runPromise(program)
// errore - regular TypeScript
const user = await fetchUser(id)
if (user instanceof Error) return []

const posts = await fetchPosts(user.id)
if (posts instanceof Error) return []

return posts.filter((p) => p.published)

Effect is powerful if you need its full feature set. But if you just want type-safe errors:

Effecterrore
Learning curveSteep (new paradigm)Minimal (just instanceof)
Codebase impactPervasive (everything becomes an Effect)Surgical (adopt incrementally)
Bundle size~50KB+~0 bytes
Resource cleanupScope + addFinalizer + acquireReleaseusing + DisposableStack.defer()
CancellationFiber interruption modelNative AbortController
Use caseFull FP frameworkJust error handling

Use Effect when you want dependency injection, structured concurrency, and the full functional programming experience.

Use errore when you just want type-safe errors without rewriting your codebase.

Zero-Dependency Philosophy

errore is more a way of writing code than a library. The core pattern requires nothing:

// You can write this without installing errore at all
class NotFoundError extends Error {
  readonly _tag = 'NotFoundError'
  constructor(public id: string) {
    super(`User ${id} not found`)
  }
}

async function getUser(id: string): Promise<User | NotFoundError> {
  const user = await db.find(id)
  if (!user) return new NotFoundError(id)
  return user
}

const user = await getUser('123')
if (user instanceof Error) return user
console.log(user.name)

The errore package just provides conveniences: createTaggedError for less boilerplate, matchError for exhaustive pattern matching, try for catching sync exceptions (and .catch() for async promises). But the core pattern—errors as union types—works with zero dependencies.

Perfect for Libraries

Ideal for library authors. Return plain TypeScript unions instead of forcing users to adopt your error handling framework:

// ❌ Library that forces a dependency on users
import { Result } from 'some-result-lib'
export function parse(input: string): Result<AST, ParseError>

// Users must now install and learn 'some-result-lib'
// ✓ Library using plain TypeScript unions
export function parse(input: string): AST | ParseError

// Users handle errors with standard instanceof checks
// No new dependencies, no new concepts to learn

Your library stays lightweight. Users get type-safe errors without adopting an opinionated wrapper. Everyone wins.

Import Style

Note: Always use import * as errore from 'errore' instead of named imports. This makes code easier to move between files, and more readable since every function call is clearly namespaced (e.g. errore.isOk() instead of just isOk()).

License

MIT

remorses/errore

Errors as values for TypeScript. Fully type safe error handling.

TypeScript

283

112 commits

updated Oct 9, 2026

See the code

README

errore

Type-safe error handling for TypeScript. Return errors instead of throwing them — as a union type (Error | T), not a wrapper. TypeScript's type narrowing does the rest: forget to handle an error and your code won't compile.

Why?

In Go, functions return errors as values instead of throwing exceptions. errore brings the same convention to TypeScript — but instead of a tuple with two separate variables, functions return a single Error | T union. You check instanceof Error instead of err != nil, and TypeScript narrows the type automatically. No wrapper types like Result<T, E>, no monads — just plain unions and instanceof:

// Go-style: errors as values
const user = await getUser(id)
if (user instanceof NotFoundError) {
  console.error('Missing:', user.id)
  return
}
if (user instanceof DbError) {
  console.error('DB failed:', user.reason)
  return
}
console.log(user.username) // user is User, fully narrowed

Install

npm install errore

Agent Skill

errore ships with a skill file that teaches AI coding agents the errore convention. Install it with:

npx -y skills add remorses/errore -g -y

Then add this to your AGENTS.md:

This codebase uses the errore.org convention. ALWAYS read the errore skill before editing any code.

Quick Start

Define typed errors with variable interpolation and return Error or Value directly:

import * as errore from 'errore'

// Define typed errors with $variable interpolation
class NotFoundError extends errore.createTaggedError({
  name: 'NotFoundError',
  message: 'User $id not found',
}) {}

class DbError extends errore.createTaggedError({
  name: 'DbError',
  message: 'Database query failed: $reason',
}) {}

// Function returns Error | Value (no wrapper!)
async function getUser(id: string): Promise<NotFoundError | DbError | User> {
  const result = await db
    .query(id)
    .catch((e) => new DbError({ reason: e.message, cause: e }))

  if (result instanceof Error) return result
  if (!result) return new NotFoundError({ id })

  return result
}

// Caller handles errors explicitly
const user = await getUser('123')

if (user instanceof Error) {
  const message = errore.matchError(user, {
    NotFoundError: (e) => `User ${e.id} not found`,
    DbError: (e) => `Database error: ${e.reason}`,
    Error: (e) => `Unexpected error: ${e.message}`,
  })
  console.log(message)
  return
}

// TypeScript knows: user is User
console.log(user.name)

Example: API Error Handling

A complete example with custom base class and HTTP status codes:

import * as errore from 'errore'

// Base class with shared functionality
class AppError extends Error {
  statusCode: number = 500

  toResponse() {
    return { error: this.message, code: this.statusCode }
  }
}

// Specific errors with status codes and $variable interpolation
class NotFoundError extends errore.createTaggedError({
  name: 'NotFoundError',
  message: '$resource not found',
  extends: AppError,
}) {}

class ValidationError extends errore.createTaggedError({
  name: 'ValidationError',
  message: 'Invalid $field: $reason',
  extends: AppError,
}) {}

class UnauthorizedError extends errore.createTaggedError({
  name: 'UnauthorizedError',

  extends: AppError,
}) {}

// Service function
async function updateUser(
  userId: string,
  data: { email?: string },
): Promise<NotFoundError | ValidationError | UnauthorizedError | User> {
  const session = await getSession()
  if (!session) {
    return new UnauthorizedError({
      message: 'Not logged in. Sign in at /login or run: mycli login',
    })
  }

  const user = await db.users.find(userId)
  if (!user) {
    return new NotFoundError({ resource: `User ${userId}` })
  }

  if (data.email && !isValidEmail(data.email)) {
    return new ValidationError({
      field: 'email',
      reason: 'Invalid email format',
    })
  }

  return db.users.update(userId, data)
}

// API handler
app.post('/users/:id', async (req, res) => {
  const result = await updateUser(req.params.id, req.body)

  if (result instanceof Error) return res.status(result.statusCode).json(result.toResponse())

  return res.json(result)
})

Hint a Solution

User-facing error messages should include how to fix the problem, not only what went wrong. Agents and CLIs that consume your API can then self-correct instead of guessing.

If the user is logged out, include the login URL or CLI command:

class UnauthorizedError extends errore.createTaggedError({
  name: 'UnauthorizedError',
  message: 'Not logged in. Sign in at $loginUrl or run: $loginCommand',
}) {}

if (!session) {
  return new UnauthorizedError({
    loginUrl: 'https://example.com',
    loginCommand: 'mycli login',
  })
}

This applies to HTTP responses, CLI output, and any error a user or agent will read. Internal errors (DB failures, parse errors) can stay technical.

API

createTaggedError

Create typed errors with variable interpolation in the message:

import * as errore from 'errore'

// Variables are extracted from the message and required in constructor
class NotFoundError extends errore.createTaggedError({
  name: 'NotFoundError',
  message: 'User $id not found in $database',
}) {}

const err = new NotFoundError({ id: '123', database: 'users' })
err.message // 'User 123 not found in users'
err.id // '123'
err.database // 'users'
err._tag // 'NotFoundError'

// Error without variables
class EmptyError extends errore.createTaggedError({
  name: 'EmptyError',
  message: 'Something went wrong',
}) {}
new EmptyError() // no args required

// Message omitted — caller provides it at construction time
class GenericError extends errore.createTaggedError({
  name: 'GenericError',
}) {}
new GenericError({ message: 'caller decides the message' })
// fingerprint is stable regardless of what message is passed

// With cause for error chaining
class WrapperError extends errore.createTaggedError({
  name: 'WrapperError',
  message: 'Failed to process $item',
}) {}
new WrapperError({ item: 'data', cause: originalError })

// With custom base class
class AppError extends Error {
  statusCode = 500
}

class HttpError extends errore.createTaggedError({
  name: 'HttpError',
  message: 'HTTP $status error',
  extends: AppError,
}) {}

const err = new HttpError({ status: 404 })
err.statusCode // 500 (inherited from AppError)
err instanceof AppError // true

Reserved variable names: $_tag, $name, $stack, $cause cannot be used in message templates — they conflict with Error internals.

Error Wrapping and Context

Wrap errors with additional context while preserving the original error via cause:

// Wrap with context, preserve original in cause
async function processUser(id: string): Promise<ServiceError | ProcessedUser> {
  const user = await getUser(id) // returns NotFoundError | User

  if (user instanceof Error) return new ServiceError({ id, cause: user })

  return process(user)
}

// Access original error via cause
const result = await processUser('123')
if (result instanceof Error) {
  console.log(result.message) // "Failed to process user 123"

  if (result.cause instanceof NotFoundError) {
    console.log(result.cause.id) // access original error's properties
  }
}

The error definitions:

import * as errore from 'errore'

class NotFoundError extends errore.createTaggedError({
  name: 'NotFoundError',
  message: 'User $id not found',
}) {}

class ServiceError extends errore.createTaggedError({
  name: 'ServiceError',
  message: 'Failed to process user $id',
}) {}

Browser console prints the full cause chain:

ServiceError: Failed to process user 123
    at processUser (app.js:12)
    at main (app.js:20)
Caused by: NotFoundError: User 123 not found
    at getUser (app.js:5)
    at processUser (app.js:8)

findCause

Walk the .cause chain to find an ancestor matching a specific error class. Similar to Go's errors.As — checks the error itself first, then traverses .cause recursively:

import * as errore from 'errore'

class NotFoundError extends errore.createTaggedError({
  name: 'NotFoundError',
  message: 'User $id not found',
}) {}

class ServiceError extends errore.createTaggedError({
  name: 'ServiceError',
  message: 'Failed to process user $id',
}) {}

// Deep chain: ServiceError -> NotFoundError
const notFound = new NotFoundError({ id: '123' })
const service = new ServiceError({ id: '123', cause: notFound })

// Instance method on tagged errors
const found = service.findCause(NotFoundError)
found?.id // '123' — type-safe access

// Standalone function for any Error
const found2 = errore.findCause(service, NotFoundError)
found2?.id // '123'

This solves the problem where result.cause instanceof MyError only checks one level deep. findCause walks the entire chain:

// A -> B -> C chain
const c = new DbError({ reason: 'connection reset' })
const b = new ServiceError({ id: '123', cause: c })
const a = new NotFoundError({ id: '456', cause: b })

// Manual check only finds B
a.cause instanceof DbError // false — only checks one level

// findCause walks the full chain
a.findCause(DbError) // finds C ✓

Returns undefined if no matching ancestor is found. Safe against circular .cause references.

Custom Base Class with extends

Use extends to inherit from a custom base class. The error will pass instanceof for both the base class and the specific error class:

import * as errore from 'errore'

class AppError extends Error {
  statusCode = 500
  toResponse() {
    return { error: this.message, code: this.statusCode }
  }
}

class NotFoundError extends errore.createTaggedError({
  name: 'NotFoundError',
  message: 'Resource $id not found',
  extends: AppError,
}) {
  statusCode = 404
}

const err = new NotFoundError({ id: '123' })
err instanceof NotFoundError // true
err instanceof AppError // true
err instanceof Error // true

err.statusCode // 404
err.toResponse() // { error: 'Resource 123 not found', code: 404 }

Type Guards

Use instanceof checks to narrow union types:

const result: NetworkError | User = await fetchUser(id)

if (result instanceof Error) return result // result is NetworkError
// result is User

Try Functions

Wrap exceptions as error values:

import * as errore from 'errore'

// Sync - wraps exceptions in UnhandledError
const parsed = errore.try(() => JSON.parse(input))

// Sync - with custom error type
const parsed = errore.try(
  () => JSON.parse(input),
  (e) => new ParseError({ reason: e.message, cause: e }),
)

// Async — prefer .catch() for promises (no wrapper needed)
const response = await fetch(url).catch(
  (e) => new NetworkError({ url, cause: e }),
)

// Async — errore.tryAsync also works, but .catch() is preferred
const response = await errore.tryAsync(
  () => fetch(url),
  (e) => new NetworkError({ url, cause: e }),
)

Best practices for try / tryAsync:

  • For async code, prefer .catch() — promise.catch((e) => new MyError({ cause: e })) is simpler and avoids the wrapper. errore.tryAsync still works but .catch() is the idiomatic choice.
  • Use errore.try for sync code — there's no equivalent of .catch() for synchronous throwing calls, so errore.try(() => JSON.parse(input)) is the right tool.
  • Use as low as possible in the call stack — only at boundaries with uncontrolled dependencies (third-party libs, JSON.parse, fetch, file I/O). Your own functions should return errors as values, never throw.
  • Keep the callback minimal — wrap only the single throwing call, not your business logic. The try callback should be a one-liner.
  • Always prefer errore.try over errore.tryFn — they are the same function, but try is the canonical name.

Transformations

Transform and chain operations:

import * as errore from 'errore'

// Transform value (if not error)
const name = errore.map(user, (u) => u.name)

// Transform error
const appError = errore.mapError(dbError, (e) => new AppError({ cause: e }))

// Chain operations
const posts = errore.andThen(user, (u) => fetchPosts(u.id))

// Side effects
const logged = errore.tap(user, (u) => console.log('Got user:', u.name))

Resource Cleanup (defer)

errore ships DisposableStack and AsyncDisposableStack polyfills for Go-like defer cleanup. Works in every runtime — no native DisposableStack support needed:

import * as errore from 'errore'

async function processRequest(id: string): Promise<DbError | Result> {
  // await using = cleanup runs automatically when scope exits
  await using cleanup = new errore.AsyncDisposableStack()

  const db = await connectDb()
  cleanup.defer(() => db.close())

  const cache = await openCache()
  cleanup.defer(() => cache.flush())

  // ... use db and cache ...
  return result
  // cleanup runs in LIFO order: cache.flush() → db.close()
}

Resources are released in reverse order (last deferred = first cleaned up), just like Go's defer. Cleanup runs on normal return, early error return, or thrown exception.

// Sync version with using
function readConfig(path: string): ParseError | Config {
  using cleanup = new errore.DisposableStack()

  const file = openFileSync(path)
  cleanup.defer(() => file.closeSync())

  const lock = acquireLock(path)
  cleanup.defer(() => lock.release())

  return parseConfig(file.readSync())
}

You can also register existing Disposable objects directly:

await using cleanup = new errore.AsyncDisposableStack()
cleanup.use(dbConnection) // calls dbConnection[Symbol.dispose]() on exit
cleanup.adopt(handle, (h) => h.close()) // custom cleanup for non-disposable values

Extraction

Extract values or throw, split arrays by success/error:

import * as errore from 'errore'

// Extract or throw
const user = errore.unwrap(result)
const user = errore.unwrap(result, 'Custom error message')

// Extract or fallback
const name = errore.unwrapOr(result, 'Anonymous')

// Pattern match
const message = errore.match(result, {
  ok: (user) => `Hello, ${user.name}`,
  err: (error) => `Failed: ${error.message}`,
})

// Split array into [successes, errors]
const [users, errors] = errore.partition(results)

Error Matching

Exhaustive pattern matching with matchError. Always assign results to a variable and keep callbacks pure:

import * as errore from 'errore'

class ValidationError extends errore.createTaggedError({
  name: 'ValidationError',
  message: 'Invalid $field',
}) {}

class NetworkError extends errore.createTaggedError({
  name: 'NetworkError',
  message: 'Failed to fetch $url',
}) {}

// Exhaustive matching - Error handler is always required
const message = errore.matchError(error, {
  ValidationError: (e) => `Invalid ${e.field}`,
  NetworkError: (e) => `Failed to fetch ${e.url}`,
  Error: (e) => `Unexpected: ${e.message}`, // required fallback for plain Error
})
console.log(message) // side effects outside callbacks

// Partial matching with fallback
const fallbackMsg = errore.matchErrorPartial(
  error,
  {
    ValidationError: (e) => `Invalid ${e.field}`,
  },
  (e) => `Unknown error: ${e.message}`,
)

// Type guards
ValidationError.is(value) // specific class

How Type Safety Works

TypeScript narrows types after instanceof Error checks:

function example(result: NetworkError | User): string {
  if (result instanceof Error) return result.message // TypeScript: result is NetworkError
  // TypeScript knows: result is User (Error excluded)
  return result.name
}

This works because:

  1. Error is a built-in class TypeScript understands
  2. Custom error classes extend Error
  3. After an instanceof Error check, TS excludes all Error subtypes

Result + Option Combined: Error | T | null

Naturally combine error handling with optional values. No wrapper nesting needed!

import * as errore from 'errore'

class NotFoundError extends errore.createTaggedError({
  name: 'NotFoundError',
  message: 'Resource $id not found',
}) {}

// Result + Option in one natural type
function findUser(id: string): NotFoundError | User | null {
  if (id === 'bad') return new NotFoundError({ id })
  if (id === 'missing') return null
  return { id, name: 'Alice' }
}

const user = findUser('123')

// Handle error first
if (user instanceof Error) return user.message // TypeScript: user is NotFoundError

// Handle null/missing case - use ?. and ?? naturally!
const name = user?.name ?? 'Anonymous'

// Or check explicitly
if (user === null) {
  return 'User not found'
}

// TypeScript knows: user is User
console.log(user.name)

Why this is better than Rust/Zig

LanguageResult + OptionOrder matters?
RustResult<Option<T>, E> or Option<Result<T, E>>Yes, must unwrap in order
Zig!?T (error union + optional)Yes, specific syntax
erroreError | T | nullNo! Check in any order

With errore you check in any order:

  • Use ?. and ?? naturally
  • Check instanceof Error or === null in any order
  • No unwrapping ceremony
  • TypeScript infers everything

Why This Is Better Than Go

Go's error handling uses two separate return values:

user, err := fetchUser(id)
// Oops! Forgot to check err
fmt.Println(user.Name)  // Compiles fine, crashes at runtime

The compiler can't save you here. You can ignore err entirely and use user directly.

With errore, forgetting to check is impossible:

const user = await fetchUser(id) // type: NotFoundError | User

console.log(user.id) // TS Error: Property 'id' does not exist on type 'NotFoundError'

Since errore uses a single union variable instead of two separate values, TypeScript forces you to narrow the type before accessing value-specific properties. You literally cannot use the value without first doing an instanceof Error check.

Note: Properties that exist on both Error and your value type (like name, message) can still be accessed without narrowing. This is a small set of 4 fields: name, message, stack, cause.

The Remaining Gap: Ignored Return Values

There's one case TypeScript alone can't catch — discarded return values:

updateUser(id, data) // returns Error | User, but result is thrown away
await fetchData(url)  // returns Error | Data, silently ignored

The caller forgot to assign the result and check instanceof Error. TypeScript won't complain because expression statements are valid syntax.

lintcn: no-unhandled-error

lintcn is the shadcn for type-aware TypeScript lint rules. You add rules by URL, own the source (Go files in .lintcn/), and customize freely. Rules use the TypeScript type checker — they see resolved types, not just syntax — so they catch things syntax-only linters can't.

lintcn ships a no-unhandled-error rule built specifically for the errore convention. It flags any expression statement where the return type includes Error (or any Error subclass) and the result is discarded:

# Install lintcn
npm install -D lintcn

# Add the no-unhandled-error rule
npx lintcn add https://github.com/remorses/lintcn/tree/main/.lintcn/no_unhandled_error

# Lint your project
npx lintcn lint

What gets flagged:

declare function getUser(id: string): Error | User

getUser("123")          // error: Error-typed return value is not handled
await fetchData("/api") // error: Promise<Error | Data> resolved but not checked
db.query("SELECT 1")   // error: Error | { rows: any[] } discarded

What is NOT flagged:

// Assigned to variable — you'll check it
const user = getUser("123")
if (user instanceof Error) return user

// Explicitly discarded with void
void getUser("123")

// void/undefined/never returns — nothing to handle
console.log("hello")
arr.push(1)

// Return statement — caller handles it
function wrapper() { return getUser("123") }

Because the rule uses the type checker, it only flags calls that return Error-typed unions — zero false positives on void-returning functions like console.log or arr.push.

The rule lives in .lintcn/no_unhandled_error/ — you own the source and can customize it. Combined with errore's instanceof Error narrowing, this closes the last gap: every error must be either handled or explicitly discarded with void.

Comparison with Result Types

Direct returns vs wrapper methods:

Result Patternerrore
Result.ok(value)just return value
Result.err(error)just return error
result.valuedirect access after guard
result.map(fn)map(result, fn)
Result<User, Error>Error | User
Result<Option<T>, E>Error | T | null

Vs neverthrow / better-result

These libraries wrap values in a Result container. You construct results with ok() and err(), then unwrap them with .value and .error:

// neverthrow
import { ok, err, Result } from 'neverthrow'

function getUser(id: string): Result<User, NotFoundError> {
  const user = db.find(id)
  if (!user) return err(new NotFoundError({ id }))
  return ok(user) // must wrap
}

const result = getUser('123')
if (result.isErr()) {
  console.log(result.error) // must unwrap
  return
}
console.log(result.value.name) // must unwrap
// errore
function getUser(id: string): User | NotFoundError {
  const user = db.find(id)
  if (!user) return new NotFoundError({ id })
  return user // just return
}

const user = getUser('123')
if (user instanceof Error) {
  console.log(user) // it's already the error
  return
}
console.log(user.name) // it's already the user

The key insight: T | Error already encodes success/failure. TypeScript's type narrowing does the rest. No wrapper needed.

Featureneverthrowerrore
Type-safe errors✓✓
Exhaustive handling✓✓
Works with nullResult<T | null, E>T | E | null
Learning curveNew API (ok, err, map, andThen, ...)Just instanceof
Bundle size~3KB min~0 bytes
InteropRequires wrapping/unwrapping at boundariesNative TypeScript

neverthrow also requires a separate plugin to catch unhandled results. With errore, TypeScript itself prevents you from using a value without checking the error first.

Vs Effect.ts

Effect is not just error handling—it's a complete functional programming framework with dependency injection, concurrency primitives, resource management, streaming, and more.

// Effect.ts - a paradigm shift
import { Effect, pipe } from 'effect'

const program = pipe(
  fetchUser(id),
  Effect.flatMap((user) => fetchPosts(user.id)),
  Effect.map((posts) => posts.filter((p) => p.published)),
  Effect.catchTag('NotFoundError', () => Effect.succeed([])),
)

const result = await Effect.runPromise(program)
// errore - regular TypeScript
const user = await fetchUser(id)
if (user instanceof Error) return []

const posts = await fetchPosts(user.id)
if (posts instanceof Error) return []

return posts.filter((p) => p.published)

Effect is powerful if you need its full feature set. But if you just want type-safe errors:

Effecterrore
Learning curveSteep (new paradigm)Minimal (just instanceof)
Codebase impactPervasive (everything becomes an Effect)Surgical (adopt incrementally)
Bundle size~50KB+~0 bytes
Resource cleanupScope + addFinalizer + acquireReleaseusing + DisposableStack.defer()
CancellationFiber interruption modelNative AbortController
Use caseFull FP frameworkJust error handling

Use Effect when you want dependency injection, structured concurrency, and the full functional programming experience.

Use errore when you just want type-safe errors without rewriting your codebase.

Zero-Dependency Philosophy

errore is more a way of writing code than a library. The core pattern requires nothing:

// You can write this without installing errore at all
class NotFoundError extends Error {
  readonly _tag = 'NotFoundError'
  constructor(public id: string) {
    super(`User ${id} not found`)
  }
}

async function getUser(id: string): Promise<User | NotFoundError> {
  const user = await db.find(id)
  if (!user) return new NotFoundError(id)
  return user
}

const user = await getUser('123')
if (user instanceof Error) return user
console.log(user.name)

The errore package just provides conveniences: createTaggedError for less boilerplate, matchError for exhaustive pattern matching, try for catching sync exceptions (and .catch() for async promises). But the core pattern—errors as union types—works with zero dependencies.

Perfect for Libraries

Ideal for library authors. Return plain TypeScript unions instead of forcing users to adopt your error handling framework:

// ❌ Library that forces a dependency on users
import { Result } from 'some-result-lib'
export function parse(input: string): Result<AST, ParseError>

// Users must now install and learn 'some-result-lib'
// ✓ Library using plain TypeScript unions
export function parse(input: string): AST | ParseError

// Users handle errors with standard instanceof checks
// No new dependencies, no new concepts to learn

Your library stays lightweight. Users get type-safe errors without adopting an opinionated wrapper. Everyone wins.

Import Style

Note: Always use import * as errore from 'errore' instead of named imports. This makes code easier to move between files, and more readable since every function call is clearly namespaced (e.g. errore.isOk() instead of just isOk()).

License

MIT

Project health

Latest release

0.14.1 · Mar 19, 2026

Releases this year

22

Significant stargazers

Tom

164 followers · starred Feb 2026

Nico Bailon

2,093 followers · starred Mar 2026

Alexander Sokolov

131 followers · starred Mar 2026

Andrei L

259 followers · starred Feb 2026