Error Handling¶
The backend returns structured error responses with a stable code field and a pre-rendered, FULL-SEMANTIC messages object localized in every supported language (en_US + it_IT). Every message is self-contained — it names the field (localized label), the enclosing elements, the entity type, and human-readable values; the frontend renders messages[lang] verbatim with zero per-code logic. Message text is owned by the i18n catalog (src/common/i18n/error-messages.catalog.ts); display labels by the label catalogs under src/common/i18n/labels/; see the backend-error-i18n specs under docs/superpowers/specs/ (2026-07-03 family + 2026-07-12 full-semantics) and the audit ledger docs/reviews/2026-07-12_error-full-semantics-audit.md.
1. AppException — The Only Throw Pattern¶
All errors are thrown via AppException. Never throw NestJS HttpException subclasses (NotFoundException, ConflictException, etc.) — use AppException with the appropriate ErrorCode instead.
AppException carries no message text. The user-facing string lives in the i18n catalog, keyed by code (+ any params.reason / params.kind discriminator) and rendered in every language by AllExceptionsFilter. Throw sites pass only code, an optional statusCode (defaults to 500), and optional structured params / data.
import { AppException, ErrorCode } from '../common';
import { HttpStatus } from '@nestjs/common';
// Simple — code + status
throw new AppException(ErrorCode.AUTH_TOKEN_INVALID, HttpStatus.UNAUTHORIZED);
// With params — structural locators + interpolation values (NOT translated text)
throw new AppException(ErrorCode.NOT_FOUND, HttpStatus.NOT_FOUND, {
params: { entity: 'student' },
});
// With data — structured error payload (import pipeline, validation)
throw new AppException(ErrorCode.IMPORT_VALIDATION_FAILED, HttpStatus.UNPROCESSABLE_ENTITY, {
data: { errors },
});
Rules:
- Always use AppException — never throw new NotFoundException() etc.
- Never catch exceptions just to re-throw — let AllExceptionsFilter handle formatting.
- Never pass message text at the throw site — add/adjust it in the i18n catalog, keyed by code (+ discriminator). A new code without a catalog entry fails assertCatalogCoverage() at boot.
- New codes must be added to the ErrorCode enum (src/common/constants/error-codes.ts) and the catalog (en_US + it_IT), plus DISCRIMINATOR_COVERAGE if the code carries a reason/kind.
- Prefer structured params (ids, field paths, counts) over prose — the catalog interpolates them; the message stays generic.
- A discriminator (reason/kind) must be thrown under params, never data. resolveCatalogKey reads the discriminator from params only; a code that documents CODE.<reason> messages but throws the reason under data silently renders the generic code-level message. The renderer consumes params entirely: text params are interpolated, the collection array (for the four collection codes — may arrive via params or data) surfaces as slim data leaves, and everything else (internal ids, declared in ERROR_INTERNAL_PARAMS) stays server-side. params is never echoed on the wire.
- Every param must be classified — text (ERROR_TEXT_PARAMS), reserved (reason/kind/field), collection (COLLECTION_ENRICHMENTS.arrayField), or internal (ERROR_INTERNAL_PARAMS). An unclassified param fails the classification-totality drift assertion. When in doubt, put it in the message.
- Use UPPER_SNAKE_CASE for all error codes.
2. Response Shape¶
Every error response carries these base fields:
{
"statusCode": 404,
"code": "NOT_FOUND",
"messages": { "en_US": "Student not found", "it_IT": "Elemento non trovato: Studente" },
"timestamp": "2026-03-10T12:00:00.000Z",
"path": "/api/v1/students/abc-123"
}
messages is the localized object the frontend renders — read messages[lang] verbatim. The legacy English message field and the legacy params echo have both been removed: everything user-meaningful is woven into the sentence, and internal ids never reach the wire (they stay at the throw site for server logs — see ERROR_INTERNAL_PARAMS). Some error codes add a top-level field locator; only the collection codes add data — see Typed Swagger Schemas below.
| Field | Description |
|---|---|
statusCode |
HTTP status code |
code |
Stable, machine-readable string. Frontend may branch on it (behavior hook, e.g. auth redirect) |
messages |
Localized, self-contained message, one string per supported language (en_US, it_IT) — pre-rendered + interpolated server-side |
field |
Optional body-relative dotted path of the offending input (Cat. 1 locator; behavior hook for form focus) |
data |
Collection codes only. Four rendered codes (VALIDATION_FAILED, IMPORT_VALIDATION_FAILED, SELECTION_VALIDATION_FAILED, SELECTION_WINDOW_PREREQ_FAILED) carry slim leaves { field?, messages }; four passthrough codes (TIMETABLE_EDIT_WOULD_VIOLATE, TIMETABLE_GENERATION_PRECHECK_FAILED, TIMETABLE_GENERATION_INFEASIBLE, TIMETABLE_GENERATION_TIMEOUT) carry pre-rendered violation cards (see ch18 §3) plus declared siblings (skippedSubjectGroups, solveMetadata) — TIMEOUT ships an empty card array but keeps the siblings |
timestamp |
ISO 8601 timestamp |
path |
Request URL path |
Messages are full-semantic¶
messages[lang] is fully rendered server-side — it contains the counts, names, dates, localized field/entity/value labels, and the specific failing rule that describe the error (e.g. "The grade 'Year 7' in Department 'High School': The Name field is required", "Teacher 'Rossi' already exists"). The frontend renders messages[lang] verbatim: no per-code logic, no template table, no params lookup. code and field remain as optional behavior hooks — nothing about the displayed text depends on them.
The label layer¶
Three localized label catalogs under src/common/i18n/labels/ back the placeholder modifiers {{key:field}}, {{key:entity}}, {{key:value}} (plus {{key:ordinal}}, which renders a 0-based position as a 1-based human ordinal, "2nd"/"2º"):
| Catalog | Keys | Used for |
|---|---|---|
field-labels.catalog.ts |
every class-validator-decorated DTO property + import column headers | "The Duration field…" / "Il campo Durata…" |
entity-labels.catalog.ts |
lowercased Prisma model names + EntityKey values + hand literals |
"Student not found" |
value-labels.catalog.ts |
every @IsEnum-reachable enum member + enum-typed text params |
"…one of: Male, Female", weekdays, statuses |
container-labels.catalog.ts |
singular nouns for array segments (best-effort, label-fallback) | "Grade 'Year 7' in Department 'High School'" |
Coverage is build-enforced by labels/label-coverage.drift.spec.ts (mechanical source scans, exact-set equality — a new DTO property, Prisma model, or enum member without a label fails the build). Arrays map element-wise through their modifier and join with ", ". Custom-field names are tenant data and render verbatim.
Enforcement¶
Which params a message must surface is declared in ERROR_TEXT_PARAMS (src/common/constants/error-codes.ts), keyed by full catalog key; entries carry their modifier ({ key, label }) or are plain strings for verbatim interpolation. error-messages.drift.spec.ts fails the build if:
- a declared text param is not interpolated (with the matching modifier) in both languages (completeness),
- a template uses an undeclared {{placeholder}} or the wrong modifier (no orphans), or
- any param of any ErrorParamsMap entry is unclassified — every param must be text (ERROR_TEXT_PARAMS), reserved (reason/kind/field), collection (the code's COLLECTION_ENRICHMENTS.arrayField), or explicitly internal (ERROR_INTERNAL_PARAMS, server-side only). A known-but-unspoken detail is mechanically impossible.
Discriminated codes (CODE.<reason|kind>) render a specific sentence per branch. Presence variants (PRESENCE_VARIANTS in render-error-messages.ts) select a richer sentence when a param is present without being a discriminator: NOT_FOUND.entity / CONFLICT.entity name the entity, CONFLICT.named additionally quotes the offending value; the placeholder-free base sentences remain for entity-less throws (third-party Nest exceptions, Prisma errors without a model name). All four collection codes compose their envelope from their leaves (up to 3 leaf sentences joined ;, plus a localized (+N more)), so a top-level-only consumer still sees the specifics.
The exhaustiveness audit (every code, key, and throw site with a disposition) lives at docs/reviews/2026-07-12_error-full-semantics-audit.md.
2a. The error-locating contract (4-category taxonomy)¶
Every error declares which of four categories it belongs to. This is the
once-and-for-all contract the frontend codes against — not "every error has a
field", because some errors are about an absence (the offending thing
isn't in the payload) or a whole-request precondition.
| Category | field |
Extra | Examples |
|---|---|---|---|
| 1. Located | exact body-relative dotted path, numeric array indices (same format as flattenValidationErrors) |
— | VALIDATION_FAILED, all SETUP_VALIDATION_FAILED element errors, CURRICULUM_HOURS_*, custom-field value errors, duplicate CONFLICT, in-tx curriculum checks |
| 2. Container + identifiers | path to the nearest containing element (or a collection name) | a typed data/param naming the absent/uncovered items |
missing_departments (data.missing), grades_not_covered (data.uncoveredGradeIds), SELECTION_WINDOW_PREREQ_FAILED (params.gaps) |
| 3. Request-level | absent | — | missing_year, server-precondition 400s |
| 4. Non-locatable / entity-state | absent | typed entity params | NOT_FOUND, permission/auth, ACADEMIC_YEAR_ARCHIVED, *_IN_USE deletion conflicts |
field is a path into this request's body, relative to its root. The same
business rule therefore yields a different root per endpoint:
- Bulk / setup curriculum (
POST /configure/setupcurriculum step,bulkSync):departments.2.curricula.0.grid.optionBlocks.0 - Single-resource (
POST/PATCH /curricula/:id):optionBlocks.0(families are flat at the body root — there is nodepartments/curricula/gridwrapper) - Single
POST /rooms:lunchShifts.0; setup rooms step:rooms.3.lunchShifts.0
Segments are always the literal request-DTO keys. The format is produced by
one helper — joinPath(prefix, ...segments) in src/common/utils/join-path.ts
— so it never drifts; src/common/utils/error-field-path.drift.spec.ts proves
every Category-1 path resolves against a representative payload.
Residual: the P2002 belt-and-suspenders catch in the curriculum/rooms bulk sync emits a collection-level
field(curricula/rooms), not an element path — the failing item isn't identifiable in the catch, and real duplicates are already caught pre-transaction with a full path.
3. Error Code Registry¶
Canonical source:
src/common/constants/error-codes.ts—ErrorCodeenum +ErrorParamsMap(typedparamsshape per code). Subsections below cover the well-trodden codes; per-domain codes for Homerooms / Subject Groups / Timetable Templates / Evaluation Scales / Curriculum Selection / Invitations / Files / Referents / Rollover / Attendance (incl.ATTENDANCE_ENTRIES_SPAN_GROUPS— see chapter 19 §13) / Grades are documented inline in their chapters and the canonical source. TheErrorParamsMapinterface ties every code to its strongly-typedparamsshape — theAppExceptionconstructor refuses to compile if a call site sends the wrong shape.
Generic Error Codes¶
| Code | Status | Params | Description |
|---|---|---|---|
INTERNAL_ERROR |
500 | — | Catch-all for unhandled server errors. Real error logged server-side, never leaked. |
VALIDATION_FAILED |
400 | — | Class-validator failures or custom field validation. See Validation Errors below. |
RATE_LIMITED |
429 | — | Rate limit exceeded. Global: 10 req/60s. Login: 5 req/60s. |
BAD_REQUEST / UNAUTHORIZED / FORBIDDEN |
400 / 401 / 403 | — | Fallback codes mapped by AllExceptionsFilter for plain NestJS BadRequestException / UnauthorizedException / ForbiddenException thrown outside the AppException discipline (typically by third-party middleware). Domain code should always throw a specific AppException. |
Authentication Error Codes¶
| Code | Status | Params | When |
|---|---|---|---|
INVALID_CREDENTIALS |
401 | — | Email exists but password does not match. |
AUTH_TOKEN_INVALID |
401 | — | JWT malformed or revoked. Refresh token invalid. User record not found. |
AUTH_TOKEN_EXPIRED |
401 | — | JWT or refresh token has passed its TTL. Distinct from AUTH_TOKEN_INVALID so the frontend can attempt a silent refresh before forcing re-login. |
AUTH_TOKEN_MISSING |
401 | — | No access token cookie or Authorization header on a protected route. |
AUTH_TOKEN_REUSE |
401 | — | Refresh token replay detected — entire token family revoked. Frontend should force re-login. |
AUTH_REFRESH_MISSING |
401 | — | No refresh token cookie in request. |
AUTH_TENANT_INVALID |
401 | — | Invalid tenant selection during multi-tenant login. |
CSRF_ORIGIN_REJECTED |
403 | — | Cookie-bearing request from a disallowed Origin / Referer (CSRF defense in depth; enforced only on NODE_ENV=production). |
ACTIVE_PROFILE_NOT_AVAILABLE |
400 / 401 | — | The active profile selected/switched to is not available for this user. 400 on select-profile / switch-profile with a profile the user doesn't hold; 401 on refresh when the stored profile is missing from the user's current DB rows, or when the refresh-token row pre-dates the activeProfile column (legacy NULL value). |
TENANT_SUSPENDED |
403 | — | Tenant is not active (suspended or trial-expired). |
USER_DEACTIVATED |
403 | — | User account is deactivated. |
Permission Error Codes¶
| Code | Status | Params | When |
|---|---|---|---|
INSUFFICIENT_SCOPE |
403 | — | User lacks the required scope+action for the route (ScopeGuard). |
ACTION_NOT_PERMITTED |
403 | — | User does not have the required action permission (ActionGuard). |
NO_AUTHENTICATED_USER |
403 | — | Permission guard ran without authenticated user. Configuration error. |
FORBIDDEN_FIELDS |
403 | — | Write request contains scope groups outside the user's writable scopes (FieldWriteGuard). |
Database Error Codes¶
| Code | Status | Params | When |
|---|---|---|---|
NOT_FOUND |
404 | { entity } |
Record not found. entity is the lowercased model/entity name (e.g. "student", "teacher"). |
CONFLICT |
409 | { entity } |
Unique constraint violation. entity from Prisma model name when available. |
FOREIGN_KEY_VIOLATION |
400 | — | Foreign key constraint failed. |
NULL_CONSTRAINT_VIOLATION |
400 | — | A required (non-nullable) field received null. |
REQUIRED_RELATION_VIOLATION |
400 | — | A required relation is missing. |
DATABASE_TIMEOUT |
503 | — | Database connection pool timeout. Clients should retry. |
SCHEMA_OUT_OF_SYNC |
500 | — | Table or column missing (Prisma P2021/P2022). Run pending migrations. |
SCHEMA_TYPE_MISMATCH |
500 | — | Stored value is incompatible with the column type (Prisma P2023). Indicates schema/data drift, typically from a manual DB edit. |
DATABASE_ERROR |
400 | — | Unmapped Prisma error code. |
Academic Year Error Codes¶
| Code | Status | Params | When |
|---|---|---|---|
ACADEMIC_YEAR_NOT_FOUND |
404 | { entity?, id? } |
Provided academicYearId not found or doesn't belong to the tenant (or is DELETED). |
NO_ACTIVE_ACADEMIC_YEAR |
409 | — | No ACTIVE academic year exists for the tenant. Tenant hasn't completed setup. |
ACADEMIC_YEAR_ARCHIVED |
409 | — | Mutation attempted on a record belonging to an archived academic year. Create, update, delete, and import are blocked. |
ACADEMIC_YEAR_GRACE_PERIOD_INVALID |
422 | { reason: 'on_or_after_year_start', gracePeriodEnding, earliestCalendarStart, departmentId } |
The incoming gracePeriodEnding lands on or after the earliest Department.calendarStartDate of that year. The onboarding deadline must precede the first day of school (ch08 §Pre-year completeness). Mirror of SETUP_VALIDATION_FAILED / calendar_start_before_grace_period on the department side. Only the incoming value is validated, so a year already in violation can always be repaired by moving the deadline earlier. |
Curriculum Selection Window Error Codes¶
| Code | Status | Params | When |
|---|---|---|---|
SELECTION_WINDOW_ALREADY_OPEN |
409 | — | A CurriculumSelectionWindow row already exists for the active academic year. |
SELECTION_WINDOW_NOT_FOUND |
404 | — | No window row exists for the active academic year. |
SELECTION_WINDOW_PREREQ_FAILED |
422 | { gaps: { departmentId, departmentName, gradeId, gradeName, activeStudentCount }[] } |
Tried to open a window while one or more (dept, grade) pairs with active students lacks a READY curriculum. |
SELECTION_WINDOW_INVALID_DATES |
422 | { reason: 'start_in_past' \| 'start_after_grace_period' \| 'grace_period_missing' \| 'grace_period_passed', startDate?, gracePeriodEnding? } |
Date validation against AcademicYear.gracePeriodEnding failed (end date is computed live, never accepted on input). |
SELECTION_WINDOW_CLOSED |
409 | — | Mutation attempted on a window whose live status is CLOSED (past gracePeriodEnding). |
Setup Wizard Error Codes¶
| Code | Status | Params | When |
|---|---|---|---|
SETUP_STEP_MISMATCH |
409 | { expected, received } |
Frontend's currentStep doesn't match backend state. User should reload. |
SETUP_INVALID_NAVIGATION |
400 | — | Cannot navigate to target step (only backward, same, or next allowed). |
SETUP_DATA_REQUIRED |
400 | — | Data is required for forward navigation. |
SETUP_STEP_INCOMPLETE |
400 | { step } |
Step not complete, can't advance. Completeness reasons are surfaced at save time as SETUP_VALIDATION_FAILED (e.g. curriculum coverage → grades_not_covered); this code is the blanket advance-gate backstop. |
SETUP_VALIDATION_FAILED |
400 | { reason, field? } |
Step-specific business rule validation failed. See reason values below. |
SETUP_VALIDATION_FAILED reason values¶
Frontend uses compound key SETUP_VALIDATION_FAILED.{reason} for translation.
field is a body-relative path (§2a, Category 1) — the column below shows the leaf segment; the full value is indexed from the request root, e.g. departments.0.periods.1 (bulk) or periods.1 (single-resource). Two reasons are Category 2 (the offending item is absent, so they carry identifiers in data instead of an element path): missing_departments → data.missing: string[]; grades_not_covered → data.uncoveredGradeIds: string[]. missing_year is Category 3 (no field).
reason |
When | Example field (leaf) |
|---|---|---|
date_range_invalid |
endDate before startDate (periods — single-day allowed); endDate not after startDate (department calendar) | calendarEndDate, periods |
calendar_start_before_grace_period |
department opens on or before the year's gracePeriodEnding (pre-year completeness invariant, ch08 §Pre-year completeness). Also carries gracePeriodEnding (YYYY-MM-DD) so the message can name the deadline |
calendarStartDate |
period_out_of_bounds |
period outside year range | periods |
overlap |
overlapping periods of same type | periods |
duplicate_name |
non-unique name (case-insensitive) | departments, grades, periods, roomTypes |
ordinal_gap |
non-sequential ordinal positions | departments, grades |
missing_departments |
not all tenant depts represented | departments |
missing_year |
no academic year found | — |
invalid_department |
department not owned by tenant | departments, departmentId |
invalid_max_capacity |
room maxCapacity not a whole number ≥ 1 | maximumCapacity |
unknown_room_type |
room references a room type not in submission | roomTypeName |
canteen_missing_shifts |
canteen room has no lunch shifts | lunchShifts |
non_canteen_has_shifts |
non-canteen room has lunch shifts | lunchShifts |
shift_invalid_range |
lunch shift endTime <= startTime | lunchShifts |
shift_overlap |
overlapping lunch shifts in same room | lunchShifts |
unresolved_room_type |
room type not found after sync | roomTypeName |
duplicate |
unique constraint violation (belt-and-suspenders) | rooms, curricula, subject_groups |
invalid_grade |
grade does not belong to specified department | gradeIds |
invalid_curriculum |
curriculum not owned by tenant | curriculumId |
grade_not_in_curriculum |
grade not linked to curriculum via CurriculumGrade | gradeId |
grades_not_covered |
one or more grades in the year have no subject offering hours (curriculum step can't complete) | curricula |
missing_hours |
neither yearLessons nor weeklyLessons provided | subjects, option blocks |
min_exceeds_max |
option block minSelections > maxSelections | optionBlocks |
Import Validation Error Codes¶
Top-level envelope uses IMPORT_VALIDATION_FAILED (422). The pipeline-internal leaves carry an ImportErrorCode + locators (column/rows/allowedValues); the renderer weaves all of them into each leaf's sentence and emits slim, message-only leaves. The envelope is composed from the leaves.
{
"statusCode": 422,
"code": "IMPORT_VALIDATION_FAILED",
"messages": {
"en_US": "Column 'Gender', rows 2-5, 8: the value must be one of: Male, Female; Column 'Last name', rows 4: a value is required",
"it_IT": "Colonna 'Genere', righe 2-5, 8: il valore deve essere uno tra: Maschio, Femmina; Colonna 'Cognome', righe 4: il valore è obbligatorio"
},
"data": {
"errors": [
{ "messages": { "en_US": "Column 'Gender', rows 2-5, 8: the value must be one of: Male, Female", "it_IT": "Colonna 'Genere', righe 2-5, 8: il valore deve essere uno tra: Maschio, Femmina" } },
{ "messages": { "en_US": "Column 'Last name', rows 4: a value is required", "it_IT": "Colonna 'Cognome', righe 4: il valore è obbligatorio" } }
]
}
}
Column names render through the field-label catalog; allowed enum members through the value-label catalog. A leaf with allowedValues is routed to the synthetic import.FIELD_INVALID.allowed_values template (renderer-selected, not a thrown discriminator). Key precedence per leaf, most-specific cataloged key first: import.<CODE>.<reason> (e.g. not_in_department) → import.<CODE>.allowed_values → import.<CODE>. Aggregator leaves always carry a reason (the rule key, usually uncataloged like invalid), so the allowed-values variant is a fallback candidate, never gated on reason absence.
| Code | Description |
|---|---|
HEADERS_MISSING |
Expected columns not found. params lists missing names. |
FIELD_REQUIRED |
A required field is empty. |
FIELD_MAX_LENGTH |
Value exceeds maximum length. |
FIELD_INVALID |
Value doesn't match allowed options. allowedValues lists valid options. |
Validation Errors¶
Produced by the global ValidationPipe (class-validator). Every leaf sentence names its field with a localized label and, when the field sits inside (nested) array elements, is prefixed with a humanized element locator harvested from ValidationError.target during the flatten walk:
{
"statusCode": 400,
"code": "VALIDATION_FAILED",
"messages": {
"en_US": "The Email field must be a valid email address; Grade 'Year 7' in Department 'High School': The Name field is required",
"it_IT": "Il campo Email deve essere un indirizzo email valido; Anno di corso 'Year 7' in Dipartimento 'High School': Il campo Nome è obbligatorio"
},
"data": {
"errors": [
{ "field": "email", "messages": { "en_US": "The Email field must be a valid email address", "it_IT": "Il campo Email deve essere un indirizzo email valido" } },
{ "field": "departments.2.grades.0.name", "messages": { "en_US": "Grade 'Year 7' in Department 'High School': The Name field is required", "it_IT": "Anno di corso 'Year 7' in Dipartimento 'High School': Il campo Nome è obbligatorio" } }
]
}
}
Each leaf is exactly { field, messages }: field is the machine locator (§2a, unchanged), messages the self-contained sentence. isEnum failures weave the localized member labels into the sentence ("…must be one of: Male, Female"). Element display names come from the element's name property by default (DISPLAY_NAME_KEYS in flatten-validation-errors.ts covers exceptions like person-shaped arrays; unnamed elements fall back to a 1-based positional phrase, "Period #2"). The location phrase is composed from the location.* catalog keys + singular container labels.
The envelope messages is composed from the leaves (all collection codes): up to 3 leaf sentences joined with ;, plus a localized (+N more) suffix beyond that. Leaf sentences are self-locating, so no raw-path prefix is added. A leafless VALIDATION_FAILED (never produced by the pipe, possible from a hand throw) keeps the generic code-level catalog text.
Passthrough rows. A COLLECTION_ENRICHMENTS row may declare passthrough: true: its leaves arrive pre-rendered at the throw site (the timetable violation cards, built by renderViolationCards — ch18 §3) and ship as-is instead of going through enrichLeaves; the envelope still composes from their messages. A row may also declare siblings: [...] — extra data fields preserved next to the array (undeclared data fields are dropped as usual). Four rows use this: TIMETABLE_EDIT_WOULD_VIOLATE (cards only), TIMETABLE_GENERATION_PRECHECK_FAILED (+skippedSubjectGroups), TIMETABLE_GENERATION_INFEASIBLE (+skippedSubjectGroups, solveMetadata), and TIMETABLE_GENERATION_TIMEOUT (same siblings as INFEASIBLE, with an empty card array).
Rule keys are runtime constraint names, not decorator names — @IsUUID() emits isUuid, @Length emits isLength, @IsISO8601 emits isIso8601. The catalog is keyed accordingly (validation.isUuid), and src/common/i18n/validation-rules.drift.spec.ts derives the emitted set mechanically (source scan + probe validation) so a new decorator without a catalog entry fails the build in both directions (missing entry / dead entry). Custom @ValidatorConstraint names and the hand-thrown custom-fields rule literals are covered by CUSTOM_CONSTRAINT_RULES / CUSTOM_FIELD_RULES in error-messages.coverage.ts. Unknown rules fall back to validation.default ("The X field is not valid"). The rule itself is renderer input and never emitted. Hand-thrown VALIDATION_FAILED builds these input leaves directly ({ field, rule, allowedValues? }) — type the throw AppException<ValidationErrorInput> (flatten-validation-errors.ts), never against the wire ValidationErrorData (its slim leaves have no rule).
Custom Fields Error Codes¶
Custom field value/definition validation errors use VALIDATION_FAILED with the same leaf shape as the pipe (field + rule, rules like required / invalidDate / invalidSelectValue — catalog-localized like any other rule). Custom field CRUD errors:
| Code | Status | When |
|---|---|---|
VALIDATION_FAILED |
400 | Wrong type, unknown field key, missing required field, invalid SELECT value, SELECT missing options, duplicate options. |
CONFLICT |
409 | Duplicate fieldKey within same tenant+entity. |
FORBIDDEN_FIELDS |
403 | User lacks WRITE access on target entity+scope. |
NOT_FOUND |
404 | Definition not found, or scope not found in permission catalogue. |
4. Typed Swagger Schemas¶
Since the envelope slim-down the base shape carries everything scalar codes emit. The code-scoped subclasses survive purely as Swagger documentation of WHICH code(s) an endpoint returns; only the two collection subclasses add a typed data.
| # | DTO | Codes | data type |
|---|---|---|---|
| 1 | ErrorResponseDto (base) |
Any ErrorCode value not covered below |
— |
| 2 | EntityErrorResponseDto |
NOT_FOUND, CONFLICT | — |
| 3 | StepMismatchErrorResponseDto / StepIncompleteErrorResponseDto / SetupValidationErrorResponseDto |
SETUP_STEP_MISMATCH / SETUP_STEP_INCOMPLETE / SETUP_VALIDATION_FAILED | — |
| 4 | ValidationErrorResponseDto |
VALIDATION_FAILED | ValidationErrorData { errors: ValidationFieldErrorDto[] } — leaves { field?, messages } |
| 5 | ImportErrorResponseDto |
IMPORT_VALIDATION_FAILED | ImportValidationData { errors: ImportValidationErrorDto[] } — leaves { messages } |
SELECTION_VALIDATION_FAILED (data.violations) and SELECTION_WINDOW_PREREQ_FAILED (data.gaps) carry the same slim message-bearing leaf shape, documented on their endpoints. The three timetable passthrough codes carry violation-card leaves instead: TIMETABLE_EDIT_WOULD_VIOLATE (data.violations: TimetableViolationDto[]) and the two generation failures (GenerationFailureDto — cards + siblings; TIMETABLE_GENERATION_TIMEOUT carries the same data shape as INFEASIBLE — an empty violations array plus skippedSubjectGroups + solveMetadata).
Source files:
- Base: src/common/dto/error-response.dto.ts
- Subclasses: src/common/dto/typed-error-responses.dto.ts
- Collection data: src/common/dto/validation-error-response.dto.ts, src/common/dto/import-validation-error.dto.ts
Swagger examples are derived, not hand-written: ERROR_EXAMPLES (src/common/constants/error-examples.ts) runs each example's spec (code + params/data input) through the real renderer + catalog at module load, so the documented envelopes are the exact wire shape by construction (error-examples.drift.spec.ts additionally rejects params/legacy fields and un-interpolated placeholders).
Runtime note: The AllExceptionsFilter continues to produce plain JSON objects. The typed DTOs are purely Swagger schema documentation — the filter's output already matches the DTO structures.
5. AppException Constructor¶
class AppException<TData = undefined, TCode extends ErrorCode = ErrorCode> extends HttpException {
constructor(
code: TCode,
statusCode?: HttpStatus, // default: 500
options?: { data?: TData; params?: ParamsFor<TCode> }, // params typed per code
);
}
No message parameter — text is resolved from the i18n catalog by AllExceptionsFilter. params is typed per code via ParamsFor<TCode> (a shape mismatch is a compile error; codes with no params entry reject any params).
Source: src/common/exceptions/app.exception.ts; catalog + renderer under src/common/i18n/.