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.
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 }
}
try/finally → using
try/finally has a structural problem:
every resource adds a nesting level. Two resources
means two levels of indentation. Three means three. The business
logic gets buried deeper with each resource you add, and the cleanup
code is split across multiple finally blocks far from
where the resource was acquired.
await using + DisposableStack fixes this.
Each resource is one cleanup.defer() call right next to
where it's created. The function stays flat — same indentation
whether you have one resource or ten. Cleanup runs automatically
in reverse order when the scope exits, on every path: normal return,
early error return, or exception.
async function importData(url: string, dbUrl: string) {
const db = await connectDb(dbUrl)
try {
const tmpFile = await createTempFile()
try {
const response = await fetch(url)
const data = await response.text()
await tmpFile.write(data)
await db.import(tmpFile.path)
return { rows: await db.count() }
} finally {
await tmpFile.delete()
}
} finally {
await db.close()
}
}
async function importData(
url: string, dbUrl: string
): Promise<ImportError | { rows: number }> {
await using cleanup = new errore.AsyncDisposableStack()
const db = await connectDb(dbUrl)
.catch(e => new ImportError({ reason: 'db connect', cause: e }))
if (db instanceof Error) return db
cleanup.defer(() => db.close())
const tmpFile = await createTempFile()
cleanup.defer(() => tmpFile.delete())
const response = await fetch(url)
.catch(e => new ImportError({ reason: 'fetch', cause: e }))
if (response instanceof Error) return response
await tmpFile.write(await response.text())
await db.import(tmpFile.path)
return { rows: await db.count() }
// cleanup: tmpFile.delete() → db.close()
}
errore ships DisposableStack and
AsyncDisposableStack polyfills that work in every
runtime. Use with TypeScript's using /
await using keywords — no native
DisposableStack support needed.
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 a separate 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.
Linting: Closing the Last Gap
TypeScript catches unhandled errors when you access properties on the
union — but there's one case it can't catch: discarded return
values. If you call a function returning
Error | T and never assign the result, TypeScript won't
complain.
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 for the
errore convention. It flags any expression statement where the return
type includes Error and the result is discarded:
npm install -D lintcn
npx lintcn add https://github.com/remorses/lintcn/tree/main/.lintcn/no_unhandled_error
npx lintcn lint
What 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
What is NOT flagged:
// Assigned — you'll check it
const user = getUser("123")
if (user instanceof Error) return user
// Explicitly discarded with void
void getUser("123")
// void/undefined returns — nothing to handle
console.log("hello")
arr.push(1)
Because the rule uses the type checker, it only flags calls
returning Error-typed unions. Zero false positives on
void-returning functions like
console.log. Combined with errore's
instanceof narrowing, this gives you complete
protection: every error must be either handled or explicitly
discarded with void.