Tagged errors are native Error instances extended with a _tag discriminant. They let you build discriminated unions of errors that TypeScript can check exhaustively — and that work with real stack traces, console.log, and error monitoring services.
import { chas } from 'ts-chas';
// Without tagged errors: error is `unknown`
try {
await fetchUser(id);
} catch (e) {
e; // unknown — you have no idea what went wrong
}
// With tagged errors: errors are in the type signature
const result = await fetchUser(id);
// Result<User, NotFoundErr | UnauthorizedErr>
Defining errors
chas.defineErrs() takes an object where each key becomes a _tag and the value is a factory function that returns the error’s extra data:
import { chas } from 'ts-chas';
const AppError = chas.defineErrs({
NotFound: (resource: string, id?: string) => ({ resource, id }),
Unauthorized: (userId: string) => ({ userId }),
Validation: (field: string, message: string) => ({ field, message }),
});
Each factory produces a real Error instance with:
- A
_tag field matching the key name
- A
name field set to the tag
- A full stack trace
- All the data properties you returned from the factory
Creating errors
Call the factory directly, or use the .err() helper to wrap it in a Result:
// Create the error instance
const e = AppError.NotFound('user', '123');
// Error { _tag: 'NotFound', name: 'NotFound', resource: 'user', id: '123', stack: '...' }
// Create an Err result in one call
const result = AppError.NotFound.err('user', '123');
// Result<never, NotFoundErr>
// Async variant
const asyncResult = AppError.NotFound.errAsync('user', '123');
// ResultAsync<never, NotFoundErr>
Inferring types
// Union of all error variants
type AppErr = chas.InferErrs<typeof AppError>;
// NotFoundErr | UnauthorizedErr | ValidationErr
// A single variant
type NotFoundErr = chas.InferErr<typeof AppError.NotFound>;
// { _tag: 'NotFound'; resource: string; id?: string } & Error
// Extract a specific variant from a union
type Auth = chas.ExtractErr<AppErr, 'Unauthorized'>;
Using errors in function signatures
function fetchUser(id: string): chas.ResultAsync<User, AppErr> {
if (!isAuthenticated()) {
return AppError.Unauthorized.errAsync(currentUserId);
}
return chas.fromPromise(
db.users.find(id),
() => AppError.NotFound('user', id)
);
}
Exhaustive matching
chas.matchErr() requires a handler for every variant. TypeScript will error if you miss one:
const result = await fetchUser(id);
if (result.isErr()) {
const message = chas.matchErr(result.unwrapErr(), {
NotFound: (e) => `${e.resource} with id ${e.id} was not found`,
Unauthorized: (e) => `User ${e.userId} lacks permission`,
Validation: (e) => `${e.field}: ${e.message}`,
});
console.error(message);
}
Partial matching
chas.matchErrPartial() lets you handle only the variants you care about. The _ handler catches everything else and receives the remaining union type:
const message = chas.matchErrPartial(error, {
NotFound: (e) => `Could not find ${e.resource}`,
_: (e) => `Something went wrong: ${e.message}`,
// `e` in `_` is typed as UnauthorizedErr | ValidationErr
});
Catching a specific tag mid-chain
.catchTag() handles a single variant inline, removing it from the error union. The remaining chain has a narrower error type:
const result = await fetchUser(id)
.catchTag(AppError.NotFound, (e) => {
console.log(`No ${e.resource} found, using guest`);
return chas.ok(GUEST_USER);
});
// Result<User, UnauthorizedErr | ValidationErr>
// NotFoundErr is no longer in the union
You can also match by tag string instead of factory:
.catchTag('NotFound', (e) => chas.ok(GUEST_USER))
// Note: `e` is less precisely typed when using a string
Tapping a tag without catching
.tapTag() runs a side effect on a specific variant but leaves the result unchanged:
const result = await fetchUser(id)
.tapTag(AppError.NotFound, (e) => {
analytics.track('resource_not_found', { resource: e.resource });
});
// Result<User, NotFoundErr | UnauthorizedErr> — unchanged
Guard integration
Use is.tagged() to check an unknown value against a tagged error factory:
import { is } from 'ts-chas/guard';
function handleError(e: unknown) {
if (AppError.NotFound.is(e)) {
// e is narrowed to NotFoundErr
console.log(`Missing: ${e.resource}`);
}
}
Each error factory has a .is(value) method as a convenience type guard. You can also use is.tagged(AppError.NotFound) from ts-chas/guard for the same effect.
Error wrapping with cause
If your factory data includes a cause property that is an Error, it is set as the native Error.cause, creating a proper error chain:
const AppError = chas.defineErrs({
Http: (status: number, cause?: Error) => ({ status, cause }),
});
const inner = new Error('Connection refused');
const outer = AppError.Http(503, inner);
outer.cause === inner; // true — full chain preserved