Bug: backend sends null for fields the contract types as optional (absent) #36

Open
opened 2026-10-03 11:43:22 +00:00 by araemer · 0 comments
Owner

Problem

The backend sends null for empty fields that @recipe/contract types as optional (field?: T), i.e. as absent, never null. Clients that trust the contract's types crash or misbehave.

Measured on the dev backend across 50 recipes (GET /recipe/:id, 375 ingredients):

Field (contract type) Sent as null
RecipeIngredientDto.subtext (subtext?: string) 375 of 375
RecipeIngredientDto.unit (unit?: string) 151
RecipeIngredientDto.amount (amount?: number) 74
RecipeIngredientGroupDto.title (title?: string) 35
createdBy on recipes and ingredient groups (AbstractDto.createdBy?: string) 47 / 65

None of these fields was ever omitted; the backend always sends the key, with null when empty. The cause is in the backend's DTO mappers, which copy the nullable database columns as they are (e.g. dto.amount = entity.amount in RecipeIngredientDtoEntityMapper.toDto).

Impact

recipe-app maps the DTOs to models typed number | undefined / string | undefined, so the null leaks into the view layer. It went unnoticed while the display code happened to tolerate it ({ing.amount ?? ""}); a shared quantity formatter that checked amount !== undefined crashed the recipe detail page for most recipes (found while verifying recipe-app#47). recipe-app#47 now normalises null → undefined in IngredientMapper for amount and unit, but that is a client-side workaround for a contract mismatch, and the other fields are untouched.

Decision needed

The contract has no rule on null vs absent, and it mixes both: some fields are explicitly T | null (avatarUrl, defaultListId, HealthResponse.commit), the recipe fields are ?: T.

Options:

  • Contract follows the backend: type the affected fields as field?: T | null (or field: T | null where the backend always sends them), and add a convention to docs/api-conventions.md. The user prefers this: explicit null over absent fields.
  • Backend follows the contract: the mappers omit empty fields instead of sending null.

Either way, docs/api-conventions.md should state the rule, and both siblings adapt: the backend mappers or the contract types, and the app mappers (recipe-app IngredientMapper would drop or keep its normalisation accordingly).

🤖 Generated with Claude Code

## Problem The backend sends `null` for empty fields that `@recipe/contract` types as optional (`field?: T`), i.e. as absent, never `null`. Clients that trust the contract's types crash or misbehave. Measured on the dev backend across 50 recipes (`GET /recipe/:id`, 375 ingredients): | Field (contract type) | Sent as `null` | |---|---| | `RecipeIngredientDto.subtext` (`subtext?: string`) | 375 of 375 | | `RecipeIngredientDto.unit` (`unit?: string`) | 151 | | `RecipeIngredientDto.amount` (`amount?: number`) | 74 | | `RecipeIngredientGroupDto.title` (`title?: string`) | 35 | | `createdBy` on recipes and ingredient groups (`AbstractDto.createdBy?: string`) | 47 / 65 | None of these fields was ever omitted; the backend always sends the key, with `null` when empty. The cause is in the backend's DTO mappers, which copy the nullable database columns as they are (e.g. `dto.amount = entity.amount` in `RecipeIngredientDtoEntityMapper.toDto`). ## Impact recipe-app maps the DTOs to models typed `number | undefined` / `string | undefined`, so the `null` leaks into the view layer. It went unnoticed while the display code happened to tolerate it (`{ing.amount ?? ""}`); a shared quantity formatter that checked `amount !== undefined` crashed the recipe detail page for most recipes (found while verifying recipe-app#47). recipe-app#47 now normalises `null` → `undefined` in `IngredientMapper` for `amount` and `unit`, but that is a client-side workaround for a contract mismatch, and the other fields are untouched. ## Decision needed The contract has no rule on `null` vs absent, and it mixes both: some fields are explicitly `T | null` (`avatarUrl`, `defaultListId`, `HealthResponse.commit`), the recipe fields are `?: T`. Options: - **Contract follows the backend:** type the affected fields as `field?: T | null` (or `field: T | null` where the backend always sends them), and add a convention to `docs/api-conventions.md`. The user prefers this: explicit `null` over absent fields. - **Backend follows the contract:** the mappers omit empty fields instead of sending `null`. Either way, `docs/api-conventions.md` should state the rule, and both siblings adapt: the backend mappers or the contract types, and the app mappers (recipe-app `IngredientMapper` would drop or keep its normalisation accordingly). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
araemer/recipe-platform#36
No description provided.