Errors as Values in TypeScript
Go-style error handling for TypeScript. Unions instead of tuples.
instanceof instead of nil checks.
In Go, functions return errors as
values instead of throwing exceptions. errore brings the same
convention to TypeScript—but instead of Go's two-value tuple (val, err), you return a single Error | T union. Instead of
checking err != nil, you check
instanceof Error. TypeScript narrows the type
automatically. Forget to check and your code won't compile.
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.username)
// ~~~~~~~~
// Error: Property 'username' 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.username) // 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, username: 'Alice' }
}
const user = findUser(id)
if (user instanceof Error) return user
const username = user?.username ?? '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.username
// ~~~~~~~~
// TS Error: Property 'username' 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.