why generics, unions and branded types need context
A year after a type decision, nobody on the team usually remembers why a specific generic constraint or a discriminated union was modeled exactly that way. A short Architecture Decision Record captures the reason, the alternatives and the consequences, so type decisions stay traceable instead of being re-guessed at every refactoring.
Table of Contents
- 1. Why Type Decisions Need Their Own ADRs
- 2. What Sets a Type ADR Apart from a Classic ADR
- 3. A Lean Template for Type ADRs
- 4. Example: An ADR for a Generic Constraint Decision
- 5. Example: An ADR for a Discriminated Union Design
- 6. Where ADRs Live and How to Link Them
- 7. Integrating ADRs into the Review Process
- 8. Handling Outdated or Superseded ADRs
- 9. ADRs Compared to Other Forms of Documentation
- 10. Summary
- 11. FAQ
1. Why Type Decisions Need Their Own ADRs
Architecture Decision Records have long been established in software development for decisions like database choice or service boundaries, but are rarely applied to type decisions within the type system itself. Yet decisions like a specific generic constraint, a discriminated union instead of optional fields, or a branded type for a special ID need just as much justification as any other architecture decision.
Without an ADR, the rationale behind a type decision usually disappears within a few months. A new teammate only sees the result in the code, not the discussion that led to it, and risks accidentally reverting a deliberate design decision because the context is missing.
A type ADR closes exactly this gap by documenting the decision, the alternatives and the consequences in one fixed, findable place, instead of leaving them buried in a pull request comment or a forgotten Slack thread.
2. What Sets a Type ADR Apart from a Classic ADR
A classic ADR usually covers large, rarely recurring decisions like choosing a framework. A type ADR, on the other hand, covers smaller but more frequent decisions within the type system, such as why a particular type was modeled as readonly or why a generic is constrained to a specific interface. That calls for a noticeably leaner template than a classic ADR with an extensive context section.
Another difference lies in the connection to the code. A type ADR should be linked directly next to the affected type definition, usually via a comment referencing the ADR number, instead of existing only in a separate architecture document that nobody consults during the actual coding work.
The frequency of type ADRs also differs markedly from classic ADRs. While a team might make two or three classic architecture decisions per quarter, type decisions with genuine discussion value arise far more often, so a heavyweight process quickly becomes a hurdle nobody follows anymore.
3. A Lean Template for Type ADRs
An effective template for type ADRs needs only a few fields: context, decision, alternatives and consequences. This brevity is deliberate, because a type ADR should be written in a few minutes, directly within the same pull request that introduces the type decision, instead of as a separate, later documentation step.
A running index matters, so every ADR stays referenceable through a stable number, even if the file name or storage location changes later. That number then appears as a comment directly in the affected code and makes the connection between decision and implementation permanently traceable.
# ADR-0014: Branded type for validated order IDs
## Status
Accepted, 2026-06-02
## Context
Plain strings were used for order IDs across the checkout module. Two
production incidents happened because a raw string from an unrelated
context (a customer ID) was accidentally passed where an order ID was
expected. The compiler could not catch this, because both were just
`string`.
## Decision
Introduce a branded type `OrderId` that wraps `string` with a unique
brand field, produced only through a validating factory function.
## Alternatives considered
- A plain type alias `type OrderId = string` — rejected, provides no
compile-time protection against mixing up different string-based IDs.
- A full class wrapper — rejected, adds runtime overhead we don't need
for a type-level-only distinction.
- Runtime validation only (zod), no branded type — rejected, catches
the problem at the API boundary but not at internal call sites.
## Consequences
- Every order ID must now go through `toOrderId()`, adding one explicit
conversion step at system boundaries (API responses, form input).
- The compiler now rejects passing a `CustomerId` where an `OrderId`
is expected, closing the exact gap that caused the two incidents.
## Related
- ADR-0001 (branded types general approach)
- ADR-0015 (supersedes this decision, see section 8)
4. Example: An ADR for a Generic Constraint Decision
Generic constraints often look like a pure style choice in code, but are frequently the result of concrete experience with faulty calls. A type ADR documenting why a generic was constrained to an interface with a specific property prevents a later person from removing the constraint as unnecessary complexity.
The direct reference in the code to the ADR number is especially valuable here, because generic constraints are often read in isolation, without knowing the original use case that made the constraint necessary.
// See ADR-0009 for why this generic is constrained to HasTimestamp.
// Without the constraint, sortByDate() compiled but silently produced
// wrong results whenever T lacked a createdAt field (returned NaN).
interface HasTimestamp {
createdAt: Date;
}
function sortByDate<T extends HasTimestamp>(items: readonly T[]): T[] {
return [...items].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}
// BEFORE ADR-0009 — this compiled without a constraint, and shipped a bug:
function sortByDateUnsafe<T>(items: readonly T[]): T[] {
return [...items].sort((a: any, b: any) => a.createdAt.getTime() - b.createdAt.getTime());
}
// Callers without createdAt silently produced NaN comparisons,
// which is exactly the incident that led the team to write ADR-0009.
const invoices: HasTimestamp[] = [{ createdAt: new Date("2026-01-01") }];
sortByDate(invoices);
5. Example: An ADR for a Discriminated Union Design
The decision to model a state as a discriminated union instead of an object with several optional fields has far reaching consequences for every subsequent access in the code. An ADR that justifies this decision prevents a later refactoring from accidentally reverting to the original, more error prone structure with optional fields.
Such an ADR is especially valuable when the discriminated union initially looks more complicated than the alternative. Without documented context, the temptation is to make the supposed simplification and lose exactly the guarantee the union was originally meant to provide.
A good type ADR for such a decision names concretely which invalid state combinations the optional fields would have allowed, for example loading and error being set at the same time. This concrete enumeration convinces more than a general reference to better type safety, because it makes the real problem the union prevents tangible.
6. Where ADRs Live and How to Link Them
Type ADRs should live in the same repository as the code they affect, usually in a docs/adr directory with consecutively numbered Markdown files. That ensures ADRs go through the same review process as code changes and get shipped automatically with a fork or checkout.
Links in the code should always use the stable ADR number, never a file path that can change when the docs directory gets reorganized. A simple comment like See ADR-0014 is enough, as long as the numbering is maintained consistently across the team.
A short index file listing every ADR title next to its number, kept at the top of the docs/adr directory, helps new teammates browse past type decisions without opening every single file. This index takes only a minute to update per ADR and pays for itself the first time someone searches for prior context.
# docs/adr/ — one file per decision, numbered consecutively, never reused
docs/adr/
0001-use-branded-types-for-ids.md
0009-generic-constraint-on-sortbydate.md
0014-branded-type-for-order-ids.md
0015-supersedes-0014-order-id-as-uuid-wrapper.md
# A small script keeps the next-number lookup trivial for contributors
next_adr_number() {
ls docs/adr | grep -oE '^[0-9]+' | sort -n | tail -1 | awk '{printf "%04d\n", $1+1}'
}
# Usage when starting a new ADR:
new_number=$(next_adr_number)
touch "docs/adr/${new_number}-short-decision-title.md"
echo "Created docs/adr/${new_number}-short-decision-title.md"
7. Integrating ADRs into the Review Process
An ADR written only weeks after the actual type decision loses accuracy, because details of the discussion have already faded. The most effective time is the pull request itself. A reviewer who sees an unusual type decision can directly request a short ADR as part of the same pull request, instead of approving the decision without context.
This integration into the review process makes ADRs a natural part of the work, instead of a separate documentation task that usually loses priority after feature completion. A team that establishes this practice quickly notices that most ADRs are written in under ten minutes while the context is still fresh.
<!-- .github/PULL_REQUEST_TEMPLATE.md — excerpt -->
## Type decisions
- [ ] This PR introduces a non-obvious type pattern (generic constraint,
discriminated union, branded type, or similar)
- [ ] If checked above: an ADR is included in this PR under `docs/adr/`
- [ ] The ADR number is referenced in a comment next to the type definition
- [ ] The ADR lists at least one alternative that was considered and rejected
- [ ] Reviewer: if an unusual type pattern is not backed by an ADR, request
one before approving, not after merge
8. Handling Outdated or Superseded ADRs
Type decisions change when requirements change, and an old ADR should then not be deleted, but marked as superseded. That preserves the historical context of why the original decision was made, while pointing to the new ADR that justifies the current decision.
A deleted ADR tears a gap into the history and leaves later teammates in the dark again about why a change was needed at all. An ADR marked as superseded, on the other hand, tells the full story of a type decision across multiple iterations.
# ADR-0014: Branded type for validated order IDs
> STATUS: Superseded by ADR-0015 (2026-09-12)
> Reason: order IDs became UUIDs after the checkout migration, the
> regex-based validation in toOrderId() no longer applied.
> See ADR-0015 for the current decision and its rationale.
## Context
(original context preserved below for historical reference,
do not delete even though the decision itself no longer applies)
...
9. ADRs Compared to Other Forms of Documentation
ADRs do not compete with other forms of documentation, they complement them at one specific point: justifying individual decisions. The following overview positions ADRs against alternatives teams frequently use instead of, or in addition to, ADRs.
The decisive yardstick in this comparison is not which form documents most extensively, but which form is still findable and trustworthy a year later. Measured against exactly that yardstick, ADRs perform noticeably better than spontaneous notes in chat tools or pull request comments.
| Documentation Form | Strength | Weakness for Type Decisions | Recommendation |
|---|---|---|---|
| Code comment | Directly visible at the code | No room for alternatives and consequences | Use as a pointer to the ADR, not a replacement |
| Wiki page | Can be extensive | Separate from the code, goes stale fast | Unsuited for type decisions |
| Pull request description | Created automatically during review | Hard to find months later | Good drafting spot, not a final destination |
| Architecture Decision Record | Stably numbered, versioned in the repository | Needs discipline to create | Best choice for type decisions |
10. Summary
An Architecture Decision Record for type decisions closes a gap classic ADRs leave open: justifying small but consequential decisions within the type system itself. A lean template with context, decision, alternatives and consequences is enough, as long as the ADR is created directly within the same pull request that introduces the type decision.
Linking it in the code through a stable ADR number and integrating it into the review process turns a one-off documentation task into a natural practice. Teams that mark outdated ADRs as superseded instead of deleting them retain a complete, traceable history of their type decisions over years.
ADRs for TypeScript type decisions, the essentials at a glance
Lean template
Context, decision, alternatives, consequences suffice, a type ADR must be writable in minutes.
Directly in the pull request
The best time is during the actual type decision, not weeks afterward.
Stable link in the code
A comment with the ADR number permanently connects decision and implementation.
Superseded, not deleted
Outdated ADRs remain as history and point to the current decision.