Errors
The AppError family exposed by the package root, the ErrorCode registry, AppEvent, and how the API translates domain errors at its boundary.
Import from @workspace/training. Every error a service throws is a subclass of the abstract AppError, a plain Error carrying a stable code and an isExpected flag. Services throw them, tests assert on them, and the consuming app translates them at its boundary. Module-specific subclasses live in the module's domain barrel (@workspace/training/domain/boards, /domain/sessions, /domain/events).
AppError
| Member | Definition |
|---|---|
code | ErrorCode | (string & {}): an ErrorCode in practice; the widening lets subclasses declared outside the domain carry their own codes while keeping autocomplete |
isExpected | true for a business refusal the caller can act on, false for an invariant violation |
name | Set to the concrete class name in the constructor ("NotFoundError", …), so logs and snapshots read well |
message | Built by each subclass from its code and fields |
isAppError(error: unknown): error is AppError | The guard, an instanceof check |
AppError.captureStackTrace is applied when the runtime offers it, so the stack starts at the throw site rather than inside the constructor.
export function requireOwned(
collection: DealCollection | null,
collectionId: string,
ctx: ServiceContext
): DealCollection {
if (!collection) {
throw new NotFoundError(ErrorCode.DEAL_COLLECTION_NOT_FOUND, collectionId);
// message => "Not found: DEAL_COLLECTION_NOT_FOUND (<id>)", isExpected => true
}
if (collection.userId !== ctx.userId) {
throw new AccessDeniedError(`deal collection ${collectionId}`);
// message => "Access denied to deal collection <id>", code => "ACCESS_DENIED"
}
return collection;
}Classes
Exposed by the package root
| Class | Constructor | code | isExpected | Extra fields | Thrown by |
|---|---|---|---|---|---|
AccessDeniedError | (resource?: string) | ACCESS_DENIED | true | A resource the actor may not reach, checked before any read and whether it exists or not: AccountsService (a foreign account), BoardService (a board or theme the actor does not own), VotingService and BookmarksService (a board the actor cannot read), DealCollectionsService, GroupSessionsService (its group and the collections it links), GroupsService, StudentsService, ThemesService, SharingGroupsService (anyone but the owner; the owner leaving their own group), BrandAssetsService (a storage key outside the account's prefix); the entitlements gate on a refusal that is neither a limit nor a feature | |
NotFoundError | (code: ErrorCode, resourceId?: string) | the given code | true | resourceId? | Billing, collections, events, groups, plans, sessions, students, subscriptions, themes, usage services |
ValidationError | (code: ErrorCode, context?: Record<string, unknown>) | the given code | true | context? | Accounts, ads, announcements, billing, boards (write gate: BOARD_GATE_REJECTED, BOARD_TRANSLATION_INCOMPLETE, BOARD_DECLARER_NOT_SOUTH), entitlements, plans, sessions services |
ConflictError | (code: ErrorCode) | the given code | true | BillingService (SUBSCRIPTION_ALREADY_ACTIVE, PLAN_GRID_PORTAL_NOT_CONFIGURED), GroupSessionsService (COLLECTION_ALREADY_LINKED), BrandAssetsService (BRAND_ASSET_SET_MISSING), AccountsService (ACCOUNT_SIGNUP_SOURCE_MISSING, ACCOUNT_HANDLE_TAKEN, ACCOUNT_HANDLE_LOCKED; the accounts repository raises ACCOUNT_HANDLE_TAKEN on a raced write) | |
DuplicateError | (code: ErrorCode) | the given code | true | Nothing in the package today; the API still translates it | |
FeatureDisabledError | (featureKey: FeatureKey) | FEATURE_DISABLED | true | featureKey | UsageAnalyticsService |
LimitReachedError | (limitKey: LimitKey, current: number, max: number) | LIMIT_REACHED | true | limitKey, current, max | Nothing in the package today; apps/api raises the wire error from EntitlementsService.can directly |
ResourceDegradedError | (resource: string, resourceId: string) | RESOURCE_DEGRADED | true | resource, resourceId | DealCollectionsService on a mutation of a collection above the owner's ceiling |
SessionTokenVerificationError | (reason: SessionTokenVerificationReason) | SESSION_TOKEN_INVALID | true | reason | The session token adapter in packages/auth |
UnexpectedError | (message: string) | UNEXPECTED_ERROR | false | BrandAssetsService guard clauses; app code for unreachable branches |
FeatureKey and LimitKey come from @workspace/training/domain/entitlements. SessionTokenVerificationReason is "malformed" | "invalid-signature" | "expired", a category safe to surface on the wire.
Exposed by a module barrel
| Class | Import path | Constructor | code | Extra fields | Thrown by |
|---|---|---|---|---|---|
BoardNotInTrashError | domain/boards | (boardId: string) | BOARD_NOT_IN_TRASH | boardId | BoardService.hardDelete when the board was not soft-deleted first |
InvalidBoardReferenceError | domain/boards | (boardIds: string[]) | INVALID_BOARD_REFERENCE | boardIds | assertBoardsAccessible, used by GroupSessionsService.addBoards when an id is missing or not readable |
DuplicateBoardInSessionError | domain/sessions | (boardIds: string[]) | DUPLICATE_BOARD_IN_SESSION | boardIds | assertNoDuplicateBoards, the pure rule behind adding boards to a session |
InvalidReplayStateError | domain/events | (id: string, currentStatus: IngestedEventStatus) | INGESTED_EVENT_INVALID_REPLAY_STATE | eventId, currentStatus | EventIngestionService.replayOne |
WebhookVerificationError | domain/events | (reason: string, cause?: unknown) | WEBHOOK_INVALID_SIGNATURE | cause? (provider error, logs only) | Webhook ingestion in application/events |
All five are isExpected: true.
const limit = new LimitReachedError("groups_per_user_max", 5, 5);
limit.code; // => "LIMIT_REACHED"
limit.limitKey; // => "groups_per_user_max"
limit.current; // => 5
limit.max; // => 5
const degraded = new ResourceDegradedError("deal-collection", "c1");
degraded.resource; // => "deal-collection"
const invalid = new ValidationError(ErrorCode.BOARD_GATE_REJECTED, {
rejections: ["missing declarer"],
});
invalid.context; // => { rejections: ["missing declarer"] }
const missingBoards = new InvalidBoardReferenceError(["b1", "b2"]);
missingBoards.boardIds; // => ["b1", "b2"]
missingBoards.name; // => "InvalidBoardReferenceError"Narrowing
isAppError separates domain errors from everything else. Narrow further with instanceof when a subclass carries fields, or switch on code when the class is generic (NotFoundError, ValidationError, ConflictError).
export function describe(error: unknown): string {
if (!isAppError(error)) {
throw error; // not a domain error: let it surface as a 500
}
const app: AppError = error;
if (!app.isExpected) {
return `bug: ${app.message}`;
}
if (error instanceof LimitReachedError) {
return `${error.limitKey}: ${error.current}/${error.max}`;
}
if (error instanceof NotFoundError) {
return `${error.code} ${error.resourceId ?? ""}`;
}
return app.code; // "ACCESS_DENIED", "GROUP_NOT_FOUND", …
}ErrorCode
ErrorCode is an as const object; the type of the same name is the union of its values. The codes are semantic: they say what happened, never how to display it. Grouped as in the source:
| Group | Codes |
|---|---|
| Access and auth | UNAUTHORIZED, ACCESS_DENIED, FORBIDDEN |
| Resources | NOT_FOUND, ALREADY_EXISTS, ALREADY_MEMBER |
| Validation | VALIDATION_ERROR, INVALID_USER |
| Sessions and lookups | SESSION_NOT_FOUND, GROUP_SESSION_NOT_FOUND, DEAL_COLLECTION_NOT_FOUND, GROUP_NOT_FOUND, SHARING_GROUP_NOT_FOUND |
| Students | STUDENT_NOT_FOUND, FIRSTNAME_REQUIRED, LASTNAME_REQUIRED, DUPLICATE_STUDENT, CANNOT_ARCHIVE_IN_GROUP |
| Accounts | ACCOUNT_NOT_FOUND, ORGANISATION_NAME_REQUIRED, ACCOUNT_SIGNUP_SOURCE_MISSING, ACCOUNT_HANDLE_INVALID, ACCOUNT_HANDLE_TAKEN, ACCOUNT_HANDLE_LOCKED |
| Catalog | CATALOG_AUTHOR_NOT_FOUND (no active account behind a handle) |
| Avatar | AVATAR_FILE_TOO_LARGE, AVATAR_INVALID_TYPE, AVATAR_UPLOAD_FAILED, AVATAR_PERSIST_FAILED |
| Public session | PUBLIC_SESSION_NOT_FOUND |
| Deals | DEAL_NOT_FOUND |
| Themes | THEME_NOT_FOUND |
| Voting and bookmarks | BOOKMARK_NOT_FOUND, VOTE_NOT_FOUND |
| Impersonation | ADMIN_REQUIRED, CANNOT_IMPERSONATE_ADMIN, NO_ACTIVE_IMPERSONATION, IMPERSONATION_CREATE_FAILED |
| Session and board membership | DUPLICATE_BOARD_IN_SESSION, COLLECTION_ALREADY_LINKED |
| Boards | BOARD_NOT_FOUND, BOARD_TRANSLATION_INCOMPLETE (a board with two or more languages must fill every non-empty localized slot in every language), BOARD_GATE_REJECTED (rejections carried as meta.rejections), BOARD_DECLARER_NOT_SOUTH (offending seat as meta.declarer), BOARD_NOT_IN_TRASH, INVALID_BOARD_REFERENCE |
| Brand assets | BRAND_ASSET_INVALID, BRAND_ASSET_SET_MISSING |
| Entitlements | FEATURE_DISABLED, LIMIT_REACHED, RESOURCE_DEGRADED |
| Plans and subscriptions | PLAN_NOT_FOUND, PLAN_GRID_NOT_FOUND, PLAN_PRICE_NOT_FOUND, SUBSCRIPTION_NOT_FOUND |
| Billing | PLAN_PRICE_NOT_ON_SALE, SUBSCRIPTION_ALREADY_ACTIVE, BILLING_CUSTOMER_NOT_FOUND, BILLING_PROVIDER_NOT_CONFIGURED, PLAN_GRID_PORTAL_NOT_CONFIGURED |
| Webhook | WEBHOOK_INVALID_SIGNATURE, WEBHOOK_SECRET_MISSING, WEBHOOK_QUEUE_UNAVAILABLE |
| Telemetry session token | SESSION_TOKEN_INVALID, SESSION_SIGNING_SECRET_MISSING |
| Ingested events | INGESTED_EVENT_NOT_FOUND, INGESTED_EVENT_INVALID_REPLAY_STATE |
| Generic class defaults | API_ERROR, UNEXPECTED_ERROR |
| Generic | UNKNOWN_ERROR |
| Export | Definition |
|---|---|
ErrorCode | The as const registry above and its value union |
isKnownErrorCode(value: string): value is ErrorCode | Membership test against a Set of the values |
Several codes are declared for the API or the apps and never thrown by the package (UNAUTHORIZED, FORBIDDEN, the avatar and impersonation groups, API_ERROR). They stay in the registry so one union covers the whole system.
AppEvent
AppEvent is the success counterpart of ErrorCode: an as const object of semantic events the apps map to notifications. The package declares them; it does not emit them.
| Group | Events |
|---|---|
| Students | STUDENT_CREATED, STUDENT_DELETED, STUDENT_ARCHIVED, STUDENT_UNARCHIVED, STUDENT_REMOVED_FROM_GROUP |
| Groups | GROUP_CREATED, GROUP_UPDATED, GROUP_DELETED, GROUP_ARCHIVED, GROUP_UNARCHIVED |
| Sessions | SESSION_CREATED, SESSION_UPDATED |
| Deals | DEAL_SAVED, DEAL_DELETED, DEAL_COPIED, DEALS_ADDED_TO_SESSION |
| Account | PROFILE_UPDATED, ONBOARDING_COMPLETED, AVATAR_UPDATED, BRAND_VISUALS_SAVED |
| Themes | THEME_CREATED, THEME_UPDATED, THEME_DELETED |
| Public links | PUBLIC_LINK_COPIED |
isKnownErrorCode("GROUP_NOT_FOUND"); // => true
isKnownErrorCode("nope"); // => false
ErrorCode.RESOURCE_DEGRADED; // => "RESOURCE_DEGRADED"
AppEvent.GROUP_ARCHIVED; // => "GROUP_ARCHIVED"The boundary
Domain errors never reach the wire. In apps/api, apps/api/src/errors/translate-domain-error.ts holds the one translation table:
- A handler wraps its service call in
translateDomainErrors(fn), or callstranslateDomainError(err)inside acatch. Both rethrow anApiErrorbuilt by theapiErrors.*factories of@workspace/api-contract. - Subclasses with fields get an explicit branch first:
InvalidBoardReferenceError,DuplicateBoardInSessionError,BoardNotInTrashError,LimitReachedError,ResourceDegradedError,FeatureDisabledError,SessionTokenVerificationError,WebhookVerificationError,AccessDeniedError. NotFoundError,ValidationError,ConflictErrorandDuplicateErrorare then looked up bycodein threePartial<Record<ErrorCode, …>>tables, so the generic classes translate per code.- An
AppErrorwith no mapping, and anything that is not anAppError, is rethrown unchanged. The error middleware logs it with its stack and answers a 500UNEXPECTED_ERRORwith a scrubbed message. That is the intended signal of a contract violation, not a silent bucket.
The admin calls the services from its server actions, so its boundary is toActionError in apps/admin/src/lib/actions/safe-action.ts: an expected AppError becomes a bad-request carrying the error's message (AccessDeniedError a forbidden), and an unexpected one is logged and masked as internal.
The wire shape and the public codes are on API errors. The teacher app receives those wire errors; it never imports a domain error class to make a UI decision.
Service Context
The ctx argument every user-scoped service method takes last, what it carries, why it is required on id-scoped methods and where it does not go.
Shared
The value objects and helpers exposed by the package root besides errors and the service context: localized fields, supported languages, calendar days, UTC month windows, label sorting and search normalisation.