errore
v0.10.0
Published
Type-safe errors as values for TypeScript. Like Go, but with full type inference.
Maintainers
Readme
errore
Type-safe errors as values for TypeScript. Like Go, but with full type inference.
Why?
Instead of wrapping values in a Result<T, E> type, functions simply return E | T. TypeScript's type narrowing handles the rest:
// 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 erroreQuick 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 errore.tryAsync({
try: () => 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',
message: '$message',
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) {
// All errors have toResponse() from AppError base
return res.status(result.statusCode).json(result.toResponse())
}
return res.json(result)
})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
// 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 // trueError 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:
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({ message: 'connection reset' })
const b = new ServiceError({ id: '123', cause: c })
const a = new ApiError({ message: 'request failed', 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:
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) {
// result is NetworkError
return result
}
// result is UserTry 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({
try: () => JSON.parse(input),
catch: e => new ParseError({ reason: e.message, cause: e })
})
// Async
const response = await errore.tryAsync(() => fetch(url))
// Async - with custom error
const response = await errore.tryAsync({
try: () => fetch(url),
catch: e => new NetworkError({ url, cause: e })
})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))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 classHow Type Safety Works
TypeScript narrows types after instanceof Error checks:
function example(result: NetworkError | User): string {
if (result instanceof Error) {
// TypeScript knows: result is NetworkError
return result.message
}
// 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 * 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
| 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 |
| errore | Error \| T \| null | No! Check in any order |
With errore 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 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
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
There's still one case errore can't catch: ignored return values:
// Oops! Completely ignoring the return value
updateUser(id, data) // No error, but we should check!For this, use TypeScript's built-in checks or a linter:
TypeScript tsconfig.json:
{
"compilerOptions": {
"noUnusedLocals": true
}
}This catches unused variables, though not ignored return values directly.
oxlint no-unused-expressions:
oxlint.json:
{
"rules": {
"no-unused-expressions": "error"
}
}Or via CLI:
oxlint --deny no-unused-expressionsCombined with errore's type safety, these tools give you near-complete protection against ignored errors.
Comparison with Result Types
Direct returns vs wrapper methods:
| Result Pattern | errore |
|---------------|--------|
| 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// 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 userThe key insight: T | Error already encodes success/failure. TypeScript's type narrowing does the rest. No wrapper needed.
| Feature | neverthrow | errore |
|---------|------------|--------|
| 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 an eslint 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:
| | Effect | errore |
|-|--------|--------|
| Learning curve | Steep (new paradigm) | Minimal (just instanceof) |
| Codebase impact | Pervasive (everything becomes an Effect) | Surgical (adopt incrementally) |
| Bundle size | ~50KB+ | ~0 bytes |
| Use case | Full FP framework | Just 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, tryAsync for catching exceptions. 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
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 justisOk()).
License
MIT
