Bridge Training

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

MemberDefinition
codeErrorCode | (string & {}): an ErrorCode in practice; the widening lets subclasses declared outside the domain carry their own codes while keeping autocomplete
isExpectedtrue for a business refusal the caller can act on, false for an invariant violation
nameSet to the concrete class name in the constructor ("NotFoundError", …), so logs and snapshots read well
messageBuilt by each subclass from its code and fields
isAppError(error: unknown): error is AppErrorThe 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.

errors.ts
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

ClassConstructorcodeisExpectedExtra fieldsThrown by
AccessDeniedError(resource?: string)ACCESS_DENIEDtrueA 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 codetrueresourceId?Billing, collections, events, groups, plans, sessions, students, subscriptions, themes, usage services
ValidationError(code: ErrorCode, context?: Record<string, unknown>)the given codetruecontext?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 codetrueBillingService (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 codetrueNothing in the package today; the API still translates it
FeatureDisabledError(featureKey: FeatureKey)FEATURE_DISABLEDtruefeatureKeyUsageAnalyticsService
LimitReachedError(limitKey: LimitKey, current: number, max: number)LIMIT_REACHEDtruelimitKey, current, maxNothing in the package today; apps/api raises the wire error from EntitlementsService.can directly
ResourceDegradedError(resource: string, resourceId: string)RESOURCE_DEGRADEDtrueresource, resourceIdDealCollectionsService on a mutation of a collection above the owner's ceiling
SessionTokenVerificationError(reason: SessionTokenVerificationReason)SESSION_TOKEN_INVALIDtruereasonThe session token adapter in packages/auth
UnexpectedError(message: string)UNEXPECTED_ERRORfalseBrandAssetsService 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

ClassImport pathConstructorcodeExtra fieldsThrown by
BoardNotInTrashErrordomain/boards(boardId: string)BOARD_NOT_IN_TRASHboardIdBoardService.hardDelete when the board was not soft-deleted first
InvalidBoardReferenceErrordomain/boards(boardIds: string[])INVALID_BOARD_REFERENCEboardIdsassertBoardsAccessible, used by GroupSessionsService.addBoards when an id is missing or not readable
DuplicateBoardInSessionErrordomain/sessions(boardIds: string[])DUPLICATE_BOARD_IN_SESSIONboardIdsassertNoDuplicateBoards, the pure rule behind adding boards to a session
InvalidReplayStateErrordomain/events(id: string, currentStatus: IngestedEventStatus)INGESTED_EVENT_INVALID_REPLAY_STATEeventId, currentStatusEventIngestionService.replayOne
WebhookVerificationErrordomain/events(reason: string, cause?: unknown)WEBHOOK_INVALID_SIGNATUREcause? (provider error, logs only)Webhook ingestion in application/events

All five are isExpected: true.

errors.ts
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).

errors.ts
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:

GroupCodes
Access and authUNAUTHORIZED, ACCESS_DENIED, FORBIDDEN
ResourcesNOT_FOUND, ALREADY_EXISTS, ALREADY_MEMBER
ValidationVALIDATION_ERROR, INVALID_USER
Sessions and lookupsSESSION_NOT_FOUND, GROUP_SESSION_NOT_FOUND, DEAL_COLLECTION_NOT_FOUND, GROUP_NOT_FOUND, SHARING_GROUP_NOT_FOUND
StudentsSTUDENT_NOT_FOUND, FIRSTNAME_REQUIRED, LASTNAME_REQUIRED, DUPLICATE_STUDENT, CANNOT_ARCHIVE_IN_GROUP
AccountsACCOUNT_NOT_FOUND, ORGANISATION_NAME_REQUIRED, ACCOUNT_SIGNUP_SOURCE_MISSING, ACCOUNT_HANDLE_INVALID, ACCOUNT_HANDLE_TAKEN, ACCOUNT_HANDLE_LOCKED
CatalogCATALOG_AUTHOR_NOT_FOUND (no active account behind a handle)
AvatarAVATAR_FILE_TOO_LARGE, AVATAR_INVALID_TYPE, AVATAR_UPLOAD_FAILED, AVATAR_PERSIST_FAILED
Public sessionPUBLIC_SESSION_NOT_FOUND
DealsDEAL_NOT_FOUND
ThemesTHEME_NOT_FOUND
Voting and bookmarksBOOKMARK_NOT_FOUND, VOTE_NOT_FOUND
ImpersonationADMIN_REQUIRED, CANNOT_IMPERSONATE_ADMIN, NO_ACTIVE_IMPERSONATION, IMPERSONATION_CREATE_FAILED
Session and board membershipDUPLICATE_BOARD_IN_SESSION, COLLECTION_ALREADY_LINKED
BoardsBOARD_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 assetsBRAND_ASSET_INVALID, BRAND_ASSET_SET_MISSING
EntitlementsFEATURE_DISABLED, LIMIT_REACHED, RESOURCE_DEGRADED
Plans and subscriptionsPLAN_NOT_FOUND, PLAN_GRID_NOT_FOUND, PLAN_PRICE_NOT_FOUND, SUBSCRIPTION_NOT_FOUND
BillingPLAN_PRICE_NOT_ON_SALE, SUBSCRIPTION_ALREADY_ACTIVE, BILLING_CUSTOMER_NOT_FOUND, BILLING_PROVIDER_NOT_CONFIGURED, PLAN_GRID_PORTAL_NOT_CONFIGURED
WebhookWEBHOOK_INVALID_SIGNATURE, WEBHOOK_SECRET_MISSING, WEBHOOK_QUEUE_UNAVAILABLE
Telemetry session tokenSESSION_TOKEN_INVALID, SESSION_SIGNING_SECRET_MISSING
Ingested eventsINGESTED_EVENT_NOT_FOUND, INGESTED_EVENT_INVALID_REPLAY_STATE
Generic class defaultsAPI_ERROR, UNEXPECTED_ERROR
GenericUNKNOWN_ERROR
ExportDefinition
ErrorCodeThe as const registry above and its value union
isKnownErrorCode(value: string): value is ErrorCodeMembership 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.

GroupEvents
StudentsSTUDENT_CREATED, STUDENT_DELETED, STUDENT_ARCHIVED, STUDENT_UNARCHIVED, STUDENT_REMOVED_FROM_GROUP
GroupsGROUP_CREATED, GROUP_UPDATED, GROUP_DELETED, GROUP_ARCHIVED, GROUP_UNARCHIVED
SessionsSESSION_CREATED, SESSION_UPDATED
DealsDEAL_SAVED, DEAL_DELETED, DEAL_COPIED, DEALS_ADDED_TO_SESSION
AccountPROFILE_UPDATED, ONBOARDING_COMPLETED, AVATAR_UPDATED, BRAND_VISUALS_SAVED
ThemesTHEME_CREATED, THEME_UPDATED, THEME_DELETED
Public linksPUBLIC_LINK_COPIED
errors.ts
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 calls translateDomainError(err) inside a catch. Both rethrow an ApiError built by the apiErrors.* factories of @workspace/api-contract.
  • Subclasses with fields get an explicit branch first: InvalidBoardReferenceError, DuplicateBoardInSessionError, BoardNotInTrashError, LimitReachedError, ResourceDegradedError, FeatureDisabledError, SessionTokenVerificationError, WebhookVerificationError, AccessDeniedError.
  • NotFoundError, ValidationError, ConflictError and DuplicateError are then looked up by code in three Partial<Record<ErrorCode, …>> tables, so the generic classes translate per code.
  • An AppError with no mapping, and anything that is not an AppError, is rethrown unchanged. The error middleware logs it with its stack and answers a 500 UNEXPECTED_ERROR with 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.