Skip to content

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/setup curriculum 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 no departments/curricula/grid wrapper)
  • 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.tsErrorCode enum + ErrorParamsMap (typed params shape 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. The ErrorParamsMap interface ties every code to its strongly-typed params shape — the AppException constructor 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_departmentsdata.missing: string[]; grades_not_covereddata.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_valuesimport.<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/.