Manifesto

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:

  1. Compile-time safety. Unhandled errors are caught by TypeScript, not by your users in production.
  2. Self-documenting signatures. The return type shows exactly what can go wrong. No need to read the implementation or hope for documentation.
  3. Error handling as expressions. No more let x; try { x = fn() } catch.... Fewer variables, less nesting, errors handled where they occur.
  4. 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.