npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@spotsccc/error-as-value

v0.14.1

Published

Type-safe errors as values for TypeScript. Like Go, but with full type inference.

Downloads

19

Readme

@spotsccc/error-as-value

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.

The package provides ESM and CommonJS entrypoints plus an installable agent skill.

Why?

In Go, functions return errors as values instead of throwing exceptions. This package 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 @spotsccc/error-as-value

Both module systems are supported:

// ESM
import { createTaggedError } from '@spotsccc/error-as-value'

// CommonJS
const { createTaggedError } = require('@spotsccc/error-as-value')

Agent Skill

@spotsccc/error-as-value ships with a skill that teaches AI coding agents the errors-as-values convention. Install it with:

npx skills add spotsccc/error-as-value

Then add this to your AGENTS.md:

This codebase uses the errors-as-values convention. Always read the error-as-value skill before editing TypeScript error handling.

Quick Start

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

import { createTaggedError, matchError } from '@spotsccc/error-as-value'

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

class DbError extends 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 = 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 { createTaggedError } from '@spotsccc/error-as-value'

// 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 createTaggedError({
  name: 'NotFoundError',
  message: '$resource not found',
  extends: AppError,
}) {}

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

class UnauthorizedError extends 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' })
  }

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

API

createTaggedError

Create typed errors with variable interpolation in the message:

import { createTaggedError } from '@spotsccc/error-as-value'

// Variables are extracted from the message and required in constructor
class NotFoundError extends 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 createTaggedError({
  name: 'EmptyError',
  message: 'Something went wrong',
}) {}
new EmptyError() // no args required

// Message omitted — caller provides it at construction time
class GenericError extends 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 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 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 { createTaggedError } from '@spotsccc/error-as-value'

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

class ServiceError extends 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 { createTaggedError, findCause } from '@spotsccc/error-as-value'

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

class ServiceError extends 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 = 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 { createTaggedError } from '@spotsccc/error-as-value'

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

class NotFoundError extends 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 { tryAsync, tryFn } from '@spotsccc/error-as-value'

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

// Sync - with custom error type
const parsed = tryFn(
  () => 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 — tryAsync also works, but .catch() is preferred
const response = await 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. tryAsync still works but .catch() is the idiomatic choice.
  • Use tryFn for sync code — there's no equivalent of .catch() for synchronous throwing calls, so tryFn(() => 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.

Transformations

Transform and chain operations:

import { andThen, map, mapError, tap } from '@spotsccc/error-as-value'

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

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

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

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

Resource Cleanup (defer)

@spotsccc/error-as-value ships DisposableStack and AsyncDisposableStack polyfills for Go-like defer cleanup. Works in every runtime — no native DisposableStack support needed:

import { AsyncDisposableStack } from '@spotsccc/error-as-value'

async function processRequest(id: string): Promise<DbError | Result> {
  // await using = cleanup runs automatically when scope exits
  await using cleanup = new 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
import { DisposableStack } from '@spotsccc/error-as-value'

function readConfig(path: string): ParseError | Config {
  using cleanup = new 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:

import { AsyncDisposableStack } from '@spotsccc/error-as-value'

await using cleanup = new 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 { match, partition, unwrap, unwrapOr } from '@spotsccc/error-as-value'

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

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

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

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

Error Matching

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

import {
  createTaggedError,
  matchError,
  matchErrorPartial,
} from '@spotsccc/error-as-value'

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

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

// Exhaustive matching - Error handler is always required
const message = 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 = 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 { createTaggedError } from '@spotsccc/error-as-value'

class NotFoundError extends 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

| Language | Result + Option | Order matters? | | ---------- | ------------------------------------------------ | -------------------------- | | Rust | Result<Option<T>, E> or Option<Result<T, E>> | Yes, must unwrap in order | | Zig | !?T (error union + optional) | Yes, specific syntax | | Error as Value | Error \| T \| null | No! Check in any order |

With this pattern 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 an Error | T union, 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 the pattern 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 errors-as-values 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 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 Pattern | Error as Value | | ---------------------- | ------------------------- | | Result.ok(value) | just return value | | Result.err(error) | just return error | | result.value | direct 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
// Error as Value
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.

| Feature | neverthrow | Error as Value | | ------------------- | -------------------------------------------- | ----------------- | | Type-safe errors | ✓ | ✓ | | Exhaustive handling | ✓ | ✓ | | Works with null | Result<T \| null, E> | T \| E \| null | | Learning curve | New API (ok, err, map, andThen, ...) | Just instanceof | | Bundle size | ~3KB min | ~0 bytes | | Interop | Requires wrapping/unwrapping at boundaries | Native TypeScript |

neverthrow also requires a separate plugin to catch unhandled results. With plain error unions, 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)
// Error as Value - 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:

| | Effect | Error as Value | | ---------------- | ------------------------------------------- | ----------------------------------- | | Learning curve | Steep (new paradigm) | Minimal (just instanceof) | | Codebase impact | Pervasive (everything becomes an Effect) | Surgical (adopt incrementally) | | Bundle size | ~50KB+ | ~0 bytes | | Resource cleanup | Scope + addFinalizer + acquireRelease | using + DisposableStack.defer() | | Cancellation | Fiber interruption model | Native AbortController | | Use case | Full FP framework | Just error handling |

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

Use Error as Value when you just want type-safe errors without rewriting your codebase.

Zero-Dependency Philosophy

Errors as values are more a way of writing code than a library. The core pattern requires nothing:

// You can write this without installing the package 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 @spotsccc/error-as-value package just provides conveniences: createTaggedError for less boilerplate, matchError for exhaustive pattern matching, tryFn 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

Import only the symbols you use directly from @spotsccc/error-as-value:

import { createTaggedError, isOk } from '@spotsccc/error-as-value'

License

MIT