Rules Hub
Coding Rules Library
Rule priority, scope & exceptions
Use this to align rules with the senior-level structure (P0/P1/P2, scope, exceptions/tradeoffs).
Model distinct entity states as a discriminated union, not one all-nullable type
When an entity has two distinct states where most fields are absent in one of them, use a union of complete shapes instead of a single type with everything nullable.
Bad example
| 1 | // Single type where almost everything is nullable to cover both states. |
| 2 | type Order = { |
| 3 | orderId: number; |
| 4 | customerName: string | null; |
| 5 | shippedAt: string | null; |
| 6 | trackingNumber: string | null; |
| 7 | carrier: string | null; |
| 8 | }; |
| 9 |
|
| 10 | // Consumers must null-check every field everywhere, and nothing |
| 11 | // stops you from reading trackingNumber on an unshipped order. |
| 12 | function renderTracking(order: Order) { |
| 13 | return order.trackingNumber!.toUpperCase(); |
| 14 | } |
Explanation (EN)
A single type with most fields nullable hides the real shape of the data: the type system can't tell a shipped order from an unshipped one, so every consumer must defensively null-check, and nothing prevents reading fields that only exist in one state.
Objašnjenje (HR)
Jedan tip s vecinom nullable polja skriva stvarni oblik podataka: tipski sustav ne moze razlikovati poslanu od neposlane narudzbe, pa svaki potrosac mora defenzivno provjeravati null, a nista ne sprjecava citanje polja koja postoje samo u jednom stanju.
Good example
| 1 | // Two complete shapes, joined as a union. Only the fields that |
| 2 | // always exist live on both variants. |
| 3 | type ShippedOrder = { |
| 4 | orderId: number; |
| 5 | customerName: string; |
| 6 | shippedAt: string; |
| 7 | trackingNumber: string; |
| 8 | carrier: string; |
| 9 | }; |
| 10 |
|
| 11 | type UnshippedOrder = { |
| 12 | orderId: number; |
| 13 | customerName: string | null; |
| 14 | shippedAt: null; |
| 15 | trackingNumber: null; |
| 16 | carrier: null; |
| 17 | }; |
| 18 |
|
| 19 | type Order = ShippedOrder | UnshippedOrder; |
| 20 |
|
| 21 | function renderTracking(order: ShippedOrder) { |
| 22 | // No null checks needed: the type guarantees the field exists. |
| 23 | return order.trackingNumber.toUpperCase(); |
| 24 | } |
Explanation (EN)
Splitting into two complete shapes and uniting them makes each state self-describing: fields that only exist when shipped are non-nullable on ShippedOrder, and functions can require the exact variant they need so consumers stop guessing.
Objašnjenje (HR)
Razdvajanje u dva potpuna oblika i njihovo spajanje u uniju cini svako stanje samoopisujucim: polja koja postoje samo kad je poslano su non-nullable na ShippedOrder, a funkcije mogu zahtijevati tocno onu varijantu koja im treba pa potrosaci vise ne nagadaju.
Exceptions / Tradeoffs (EN)
If the two states share almost all fields and only one or two are conditionally absent, a single type with those few optional fields is fine and simpler. Balance against discriminated-union-only-when-variants-diverge: use a discriminated union when states diverge enough that most fields are absent or invalid in one state.
Iznimke / Tradeoffi (HR)
Ako dva stanja dijele gotovo sva polja i samo jedno ili dva uvjetno nedostaju, jedan tip s tih nekoliko opcionalnih polja je u redu i jednostavniji.