@spotsccc/error-as-value
v0.14.1
Published
Type-safe errors as values for TypeScript. Like Go, but with full type inference.
Downloads
19
Maintainers
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 narrowedInstall
npm install @spotsccc/error-as-valueBoth 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-valueThen 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 // trueReserved 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 UserTry 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.tryAsyncstill works but.catch()is the idiomatic choice.- Use
tryFnfor sync code — there's no equivalent of.catch()for synchronous throwing calls, sotryFn(() => 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
trycallback 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 valuesExtraction
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 classHow 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:
Erroris a built-in class TypeScript understands- Custom error classes extend
Error - After an
instanceof Errorcheck, 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 Erroror=== nullin 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 runtimeThe 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
Errorand your value type (likename,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 ignoredThe 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 lintWhat 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[] } discardedWhat 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 userThe 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 learnYour 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
