A concise, type-safe error handling toolkit for TypeScript inspired by Rust's thiserror. Use the
@thaterror/core package to define domain-driven error families with zero boilerplate, then adopt
or serialize them with optional adapters (for example, a pino adapter is available).
Handling Error in large-scale TypeScript projects can be frustrating:
instanceofis not always reliable across different packages, multiple versions, or due to structural typing matches.- Error context (Payload) is often lost during propagation.
- Integrating third-party errors (e.g.,
Hono,TypeORM,SyntaxError) into your domain model usually requires messy manual conversion.
thaterror solves these with a Schema-first philosophy, bringing Rust-like ergonomics to TypeScript error
handling.
- π― Zero Boilerplate: A single
Thatcall generates error factories with built-in type guards and payload support. - ποΈ Domain-Driven: Define error families that encapsulate your business logic.
- π Native Integration: "Naturalize" external errors into your family using
enrollandbridge. - π§ Intelligent Transformation: The
frommethod provides strict type checking, ensuring only registered error types are processed. - π¦Ύ Total Type Safety: Perfect type narrowing that automatically infers payload types from your schema.
- π¦ thiserror-like Experience: Declarative, robust, and designed for developers who value type correctness.
This repository is split into focused packages:
- @thaterror/core β the main library: how to define errors, strong typing, adapters.
- @thaterror/pino-adapter β a small adapter to serialize
ThatErrorinstances forpino.
Installation
To use the core library:
bun add @thaterror/core
// or with npm
npm install @thaterror/coreIf you want the pino adapter for structured logging:
bun add @thaterror/pino-adapter pinoSee the individual package READMEs for development and testing instructions.
Rust (thiserror) π¦
use thiserror::Error;
// Add serde derives for easy serialization/deserialization
use serde::{Serialize, Deserialize};
#[derive(Debug, Error, Serialize, Deserialize)]
pub enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("invalid input: {0}")]
InvalidInput(String),
// Use a serializable payload in the README example to keep the serde example accurate.
#[error("db error: {0}")]
DbError(String),
}
// Quick notes for thiserror:
// - Type & payload: enums carry payloads at the type level and enable exhaustive matching.
// - Pattern matching: use `match` for exhaustive, compile-time checked branching.
// - Serialization: straightforward with `serde` β derive `Serialize`/`Deserialize`. For a clean
// discriminated JSON shape, add a serde tag on the enum.
// - Cost: language-level safety and performance; highly reliable for in-binary error modeling.TypeScript (@thaterror/core) π‘οΈ
import {That, type ThatError} from '@thaterror/core';
const AppError = That({
NotFound: (id: string) => `not found: ${id}`,
InvalidInput: (input: string) => `invalid input: ${input}`,
DbError: (query: string) => `db error: ${query}`,
});
type AppErrorType = ThatError<typeof AppError>;
throw AppError.NotFound('123');
// Quick notes for @thaterror/core:
// - Type & payload: carries payloads in the type system and supports safe narrowing.
// - Pattern matching: built-in type guards enable exhaustive, type-safe branching.
// - Cross-package reliability: guards and schema-style definitions are robust across modules.
// - Serialization & adapters: ships adapters (e.g. pino) for structured logging/transport.
// - Cost: minimal boilerplate with strong TypeScript typing.Use @thaterror/core with neverthrow β full Rust-like Result<T, E> experience π¦
// Combine @thaterror/core (typed errors) with neverthrow's Result for ergonomic,
// exhaustiveness-friendly flow control similar to Rust's `Result<T, E>`.
import {type ThatError} from '@thaterror/core';
import {errAsync, ResultAsync} from 'neverthrow';
import {AppError} from './error'
import {dbFind} from 'db'; // assume dbFind(id) returns Promise<User | undefined>
type AppErrorType = ThatError<typeof AppError>;
// Async neverthrow example function to find a user by ID:
// - returns ResultAsync<User, AppError>
const findUser = (id: string): ResultAsync<T, AppErrorType> => {
// Immediate validation using neverthrow's async helpers (no try/catch)
if (!id) return errAsync(AppError.InvalidInput(id));
// Wrap the DB promise and map any rejection into AppError
return ResultAsync.fromPromise(
dbFind(id),
// asume e.query is string
(e) => AppError.DbError(e.query).with({cause: e})
).andThen(r => {
if (!r) return errAsync(AppError.NotFound(id));
return ResultAsync.ok(r);
})
};TypeScript (native Error)
class NotFoundError extends Error {
constructor(public id: string) {
super(`not found: ${id}`);
this.name = 'NotFoundError';
}
}
class InvalidInputError extends Error {
constructor(public input: string) {
super(`invalid input: ${input}`);
this.name = 'InvalidInputError';
}
}
class DbError extends Error {
constructor(public query: string) {
super('db error');
this.name = 'DbError';
}
}
throw new NotFoundError('123')
// Quick notes for native Error:
// - Lack of scope: no built-in grouping of related errors.
// - Type & payload: requires manual extension; TypeScript typing is weaker compared to enums/That.
// - Pattern matching: consumers often use `instanceof` or `error.name`, which is less safe.
// - Cross-package reliability: `instanceof` can be fragile across bundles/versions.
// - Serialization: needs explicit conversion for structured logs/transport.
// - Cost: simplest to implement but lacks the type-level guarantees of the other approaches.