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 Error) return user
console.log(user.name)
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) {
...
}
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 }
}