Errors as Values in TypeScript
No wrappers. No exceptions. Just unions.
Errors are not exceptional—they are inevitable. Instead of throwing exceptions and hoping someone catches them, return errors as values. Make them part of the type signature. Let the compiler enforce that every error is handled.
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
Functions return errors in their type signature. Callers check with instanceof Error. TypeScript narrows the type automatically. That's it.
// The return type tells the truth
async function getUser(id: string): Promise<NotFoundError | User> {
const user = await db.find(id)
if (!user) return new NotFoundError({ id })
return user
}
If you forget to handle the error, your code won't compile:
const user = await getUser(id)
console.log(user.name)
// ~~~~
// Error: Property 'name' does not exist on type 'NotFoundError'
This gives you:
- Compile-time safety. Unhandled errors are caught by TypeScript, not by your users in production.
- Self-documenting signatures. The return type shows exactly what can go wrong. No need to read the implementation or hope for documentation.
- Error handling as expressions. No more
let x; try { x = fn() } catch.... Fewer variables, less nesting, errors handled where they occur. - Trackable error flow. Create custom error classes. Trace them through your codebase. Like Effect, but without the learning curve.
Expressions instead of blocks. Error handling stays linear:
// With errore: error handling is an expression
const config = parseConfig(input)
if (config instanceof Error) return config
const db = connectDB(config.dbUrl)
if (db instanceof Error) return db
// BAD: with try-catch, error handling is a block
let config: Config
let db: Database
try {
config = parseConfig(input)
db = connectDB(config.dbUrl)
} catch (e) {
...
}
AI Agents
errore is perfect for AI coding agents. When an agent writes code with try-catch, errors are invisible—the agent can forget a catch block, swallow an exception, or miss an error path entirely. With errore, the compiler won't let it. Every error is in the return type. The agent must handle it with instanceof before it can access the value. Unhandled errors are compile errors, not runtime surprises discovered in production.
Install the skill file:
npx skills add remorses/errore
Then add this to your AGENTS.md:
This codebase uses the errore.org convention.
ALWAYS read the errore skill before editing any code.
Better than Go. This is Go-style error handling—errors as values, not exceptions. But with one key difference: Go's two return values let you ignore the error and use the value anyway. A single union makes that impossible:
// Go: you can forget to check err
user, err := fetchUser(id)
fmt.Println(user.Name) // Compiles fine. Crashes at runtime.
// TypeScript + errore: you cannot forget
const user = await fetchUser(id)
console.log(user.name) // Won't compile until you handle the error.
Errors and nulls together. Use ?. and ?? naturally:
// Errors and nulls work together naturally
function findUser(id: string): NotFoundError | User | null {
if (id === 'invalid') return new NotFoundError({ id })
if (id === 'missing') return null
return { id, name: 'Alice' }
}
const user = findUser(id)
if (user instanceof Error) return user
const name = user?.name ?? 'Guest'
Tagged Errors
For more structure, create typed errors with $variable interpolation:
class NotFoundError extends errore.createTaggedError({
name: 'NotFoundError',
message: 'User $id not found'
}) {}
class NetworkError extends errore.createTaggedError({
name: 'NetworkError',
message: 'Request to $url failed'
}) {}
const err = new NotFoundError({ id: '123' })
err.message // "User 123 not found"
err.id // "123"
Pattern match with matchError. It's exhaustive—the compiler errors if you forget to handle a case:
// Exhaustive matching - compiler errors if you miss a case
const message = errore.matchError(error, {
NotFoundError: e => `User ${e.id} not found`,
NetworkError: e => `Failed to reach ${e.url}`,
Error: e => `Unexpected: ${e.message}`
})
// Forgot NotFoundError? TypeScript complains:
errore.matchError(error, {
NetworkError: e => `...`,
Error: e => `...`
})
// TS Error: Property 'NotFoundError' is missing in type '{ NetworkError: ...; Error: ...; }'
Same with instanceof. TypeScript tracks which errors you've handled. Forget one, and it won't compile:
async function getUser(id: string): Promise<NotFoundError | NetworkError | ValidationError | User>
const user = await getUser(id)
if (user instanceof NotFoundError) return 'not found'
if (user instanceof NetworkError) return 'network issue'
// Forgot ValidationError? TypeScript knows:
return user.name
// ~~~~
// TS Error: Property 'name' does not exist on type 'ValidationError'
This guarantees every error flow is handled. No silent failures. No forgotten edge cases.
Migration
try-catch with multiple error types:
try {
const user = await getUser(id)
const posts = await getPosts(user.id)
const enriched = await enrichPosts(posts)
return enriched
} catch (e) {
if (e instanceof NotFoundError) { console.warn('User not found', id); return null }
if (e instanceof NetworkError) { console.error('Network failed', e.url); return null }
if (e instanceof RateLimitError) { console.warn('Rate limited'); return null }
throw e // unknown error, hope someone catches it
}
const user = await getUser(id)
if (user instanceof NotFoundError) { console.warn('User not found', id); return null }
if (user instanceof NetworkError) { console.error('Network failed', user.url); return null }
const posts = await getPosts(user.id)
if (posts instanceof NetworkError) { console.error('Network failed', posts.url); return null }
if (posts instanceof RateLimitError) { console.warn('Rate limited'); return null }
const enriched = await enrichPosts(posts)
if (enriched instanceof Error) { console.error('Processing failed', enriched); return null }
return enriched
Parallel operations with Promise.all:
try {
const [user, posts, stats] = await Promise.all([
getUser(id),
getPosts(id),
getStats(id)
])
return { user, posts, stats }
} catch (e) {
// Which one failed? No idea.
console.error('Something failed', e)
return null
}
const [user, posts, stats] = await Promise.all([
getUser(id),
getPosts(id),
getStats(id)
])
if (user instanceof Error) { console.error('User fetch failed', user); return null }
if (posts instanceof Error) { console.error('Posts fetch failed', posts); return null }
if (stats instanceof Error) { console.error('Stats fetch failed', stats); return null }
return { user, posts, stats }
Wrapping libraries that throw:
function parseConfig(input: string): Config {
return JSON.parse(input) // throws on invalid JSON
}
function parseConfig(input: string): ParseError | Config {
const result = errore.try(() => JSON.parse(input))
if (result instanceof Error) return new ParseError({ reason: result.message })
return result
}
Validation:
function createUser(input: unknown): User {
if (!input.email) throw new Error('Email required')
if (!input.name) throw new Error('Name required')
return { email: input.email, name: input.name }
}
function createUser(input: unknown): ValidationError | User {
if (!input.email) return new ValidationError({ field: 'email', reason: 'required' })
if (!input.name) return new ValidationError({ field: 'name', reason: 'required' })
return { email: input.email, name: input.name }
}
Resource Cleanup
errore ships DisposableStack polyfills for Go-like defer cleanup. Use with TypeScript's using keyword — cleanup runs automatically when the scope exits, in reverse order:
// Nested try/finally for each resource
async function processOrder(orderId: string) {
const db = await connectDb()
try {
const cache = await openCache()
try {
const order = await db.query(orderId)
const receipt = await processPayment(order)
await cache.set(orderId, receipt)
return receipt
} finally {
await cache.flush()
}
} finally {
await db.close()
}
}
// Go-like defer with await using
async function processOrder(orderId: string): Promise<DbError | Receipt> {
await using cleanup = new errore.AsyncDisposableStack()
const db = await errore.tryAsync({
try: () => connectDb(),
catch: (e) => new DbError({ orderId, cause: e }),
})
if (db instanceof Error) return db
cleanup.defer(() => db.close())
const cache = await openCache()
cleanup.defer(() => cache.flush())
const order = await db.query(orderId)
const receipt = await processPayment(order)
await cache.set(orderId, receipt)
return receipt
// cleanup runs automatically: cache.flush() → db.close()
}
await using guarantees cleanup on every exit path — normal return, early error return, or exception. No try/finally nesting. Adding more resources is just another cleanup.defer().
Vs neverthrow / better-result
These libraries wrap values in a Result<T, E> container. You construct with ok() and err(), then unwrap with .value and .error:
// neverthrow / better-result
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.
neverthrow requires an eslint plugin to catch unhandled results. With errore, TypeScript itself prevents 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, resource management, 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)
Use Effect when you want DI, structured concurrency, and the full FP experience. Use errore when you just want type-safe errors without rewriting your codebase. For resource cleanup, Effect uses Scope + acquireRelease + addFinalizer. errore uses native using + DisposableStack.defer() — same guarantee, zero framework.
See the full side-by-side comparison →
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 provides conveniences: createTaggedError for less boilerplate, matchError for exhaustive matching, tryAsync for catching exceptions. But the pattern—errors as union types—works with zero dependencies.
Perfect for Libraries
This approach is ideal for library authors. Instead of forcing users to adopt your error handling framework:
// ❌ Library that forces a dependency
import { Result } from 'some-result-lib'
export function parse(input: string): Result<AST, ParseError>
// Users must install and learn 'some-result-lib'
// ✓ Library using plain TypeScript unions
export function parse(input: string): AST | ParseError
// Users handle errors with standard instanceof
// No new dependencies, no new concepts
Your library stays lightweight. Users get type-safe errors without adopting an opinionated wrapper.