Encode-side key presence for optional nullable fields
- Dominant language
- Dart
- Stars
- 63
- Forks
- 3
- Avg merge
- 15h 12m
- Merged PRs (30d)
- 12
Description
## Summary
Parse already distinguishes three JSON states for an optional nullable property:
| Wire | Dart after parse |
| --- | --- |
| key omitted | `null` |
| `"payload": null` | `null` |
| `"payload": ` | value |
`@Optional()` / `@Required()` / `@NotNull()` are parse-side. Unannotated `T?` is `.optional().nullable()`: missing and present-null both become Dart `null`. Generated encode then omits Dart null (`includeIfNull: false`).
`T?` is one slot. After parse, omit and present-null are the same value, so encode cannot write `"payload": null` even when the key was present.
#145 called this out as a non-goal. It is still true after #146.
## Why this is not #145 / #146
#146 infers `Object?` → `Ack.any().nullable()` and `Map` → `Ack.map(...)`. That is the type table. It does not add a presence bit.
`@NotNull()` rejects present JSON null on parse. It does not tell encode “this key was present.”
Flipping `includeIfNull` only swaps which case is lost: always emit null, or always omit. Consumers that must round-trip both still cannot.
## Current consumer workaround
A handwritten flag next to the field:
```dart
final class Envelope {
Envelope({this.payload, this.hasPayload = false});
final Object? payload;
final bool hasPayload;
Map toJson() => {
if (hasPayload) 'payload': payload,
};
}
```
`hasPayload` is not a JSON field, so the class cannot be `@AckModel`. The payload itself is ordinary JSON-any and would generate fine under #146.
## Proposed direction
An encode-side presence type, not a parse annotation.
Something like `JsonMaybe` / `JsonPresence` with three states: absent, present-null, value. Class-first infers it to `.optional().nullable()` on parse and uses the presence tag on encode.
Do not:
- flip global `includeIfNull`
- overload `@NotNull()` to mean encode presence
- treat a `bool hasX` field as a JSON key
Omit-if-empty / omit-default (`{}`, `false`, empty lists) is a separate policy. This issue is only omit vs present-null vs value.
## Acceptance
- A generated model can parse `{"payload": null}` and encode that same key back as null.
- Omitted input still encodes as omitted.
- `T?` keeps today’s collapse. Presence is opt-in via the new type (or an explicit field annotation that is not `@NotNull()`).
- #146 stays mergeable without this.
## Related
#145 (non-goal: three-way key presence / `JsonMaybe`). #146 implements the type table only.
Contributor guide
Research direction
No implementation files or tests are named. Start by tracing the generated parse and encode paths discussed alongside #146, then compare the behavior with the stated three-state acceptance cases: present-null round-trips, omitted input stays omitted, and ordinary T? behavior remains unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- dart
- Domain
- backend-api-design
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100