@ttoss/errors
A zero-dependency contract for expected errors: outcomes an operation anticipates and handles — invalid input, a missing resource, a degraded state already dealt with. They still reach the caller, so the message is shown, but they are not faults, so an error tracker should not open an issue for them. Bugs, infrastructure failures and unexpected third-party errors stay a plain Error.
Installation
pnpm add @ttoss/errors
Usage
Throw an expected error where the condition is detected:
import { NotFoundError, ValidationError } from '@ttoss/errors';
if (!input.name) {
throw new ValidationError('name is required');
}
if (!project || project.ownerId !== userId) {
// Same error for "missing" and "not yours", so a response never reveals
// that a resource the caller cannot see exists.
throw new NotFoundError('project not found');
}
Filter it once, at the reporting boundary:
import { isExpectedError } from '@ttoss/errors';
try {
await handler(event);
} catch (error) {
if (!isExpectedError(error)) {
reportToErrorTracking(error);
}
throw error;
}
isExpectedError matches the expected: true marker, not the class, so it holds when a bundler inlines its own copy of this package.
Extending
Subclass ExpectedError for your own kinds. name defaults to the subclass name:
import { ExpectedError } from '@ttoss/errors';
class PaymentDeclinedError extends ExpectedError {}
throw new PaymentDeclinedError('card declined', { code: 'PAYMENT_DECLINED' });
Narrow code to the values your error can carry with the type parameter:
class CardError extends ExpectedError<'CARD_DECLINED' | 'CARD_EXPIRED'> {}
new CardError('declined', { code: 'CARD_DECLINED' }).code; // 'CARD_DECLINED' | 'CARD_EXPIRED'
A minifier that renames classes renames name too. When something downstream matches on it (an error type a client branches on), pin it:
class PaymentDeclinedError extends ExpectedError {
override name = 'PaymentDeclinedError';
}
An error that is expected only in some cases does not need to extend anything. Set the marker on the instance and isExpectedError recognizes it:
class ProviderError extends Error {
expected?: true;
constructor(status: number) {
super(`provider answered ${status}`);
if (status < 500) {
this.expected = true;
}
}
}
Localized messages
Pass a code and a messageRef and the error is also a localized error by shape, so the @ttoss/i18n-core boundaries in @ttoss/http-server and @ttoss/appsync-api render it in the request locale:
throw new ValidationError('plan limit reached', {
code: 'PLAN_LIMIT',
messageRef: msg(messages.planLimit),
});
The two ideas are independent: "localized" decides how the message is shown, "expected" decides whether it is reported.
Code plus values, when the thrower does not own the copy
A domain package often should not hold user-facing copy — it has no locale, and several boundaries (an API, an agent tool) may word the same failure differently. It throws a code and the values the message needs instead, and the boundary that owns the copy builds the messageRef from them:
// domain package: data only
export type InputErrorValues = {
failures: Array<{
field: string;
reason: 'required' | 'min';
limit?: number;
}>;
};
throw new ValidationError('Invalid input', {
code: 'INPUT_INVALID',
values: { failures },
});
// boundary: owns the copy, e.g. through `resolveMessageRef` in @ttoss/appsync-api
const resolveMessageRef = ({ error }) => {
return error.code === 'INPUT_INVALID'
? msg(messages.inputInvalid, { count: error.values.failures.length })
: undefined;
};
values is JSON-safe data — primitives, arrays, records and MessageRefs — so it survives serialization, and a value can point at copy its owner already defines. Pass numbers and dates raw and let the renderer format them; a string formatted where no locale is known is wrong for every other locale. Declare a values shape with type, not interface: an interface has no index signature, so TypeScript rejects it as a record.
Narrow both per error with the type parameters, so the placeholders the copy interpolates are a checked contract:
class LimitError extends ExpectedError<'OVER_LIMIT', { limit: number }> {}
ValidationError and NotFoundError infer both from the call.