CONCEPT Cited by 1 source
Final enum¶
Definition¶
A final enum is a GraphQL enum value (or an entire
enum, by marking all values) annotated with a directive —
in Zalando's case @final on ENUM_VALUE — that signals to
schema-evolution tooling that no new values may be added
to this enum without explicit opt-out. The directive has
zero runtime behaviour: it is inspected only by a
build-time schema
linter that rejects any PR introducing a new value on a
@final-marked enum.
The pattern's purpose is to force a multi-PR ritual around a schema change that is technically not a breaking change but is a "dangerous change" — an addition whose backward-compatibility guarantee only holds if every production client handles unknown enum values defensively.
Why enum addition is a dangerous change¶
GraphQL's compatibility rules classify adding a value to an enum as non-breaking: the new value does not change the structure of existing responses, and old queries continue to execute. But at the client-side runtime, "non-breaking" hinges on whether the client code has a handler for the new value:
- Web clients can be updated in the time it takes to push a new bundle (minutes); unknown-value exposure is short-lived.
- Native mobile clients shipped through app stores cannot be updated synchronously. An old app binary running in a customer's pocket for 6+ months will receive the new enum value and may crash, render nothing, or fall through to wrong UX if its handler is a switch that doesn't cover the new case.
Zalando's post is explicit: "It is easy to update the client code for web applications, but for the mobile native apps shipped to the app store, it is impossible to update the client code. Though we practice defensive coding practices to handle unknown values, we still need a way to control the evolution of enums in a safe manner." (Source: sources/2023-10-18-zalando-understanding-graphql-directives-practical-use-cases-at-zalando).
The @final directive is the organisational lever that
makes defensive coding also a schema-level policy.
The two-PR ritual¶
@final deliberately adds friction. To add a value to a
@final-marked enum:
- PR 1 — remove
@final. Justify in review why old clients will not break (evidence of unknown-value handling, deprecated-client population share, app-store version distribution, etc.). Merge. - PR 2 — add the new value. Can now proceed because the linter is no longer blocking. Merge.
The author writes: "This process is cumbersome, but it is on purpose. It must be more complicated to make dangerous changes, and it is a trade-off we are willing to make."
The two-PR ritual is a pattern that appears elsewhere under
different names — TypeScript's @ts-expect-error
annotation, Rust's #[deny(...)] + #[allow(...)]
override — and shares the same property: the dangerous
action requires an explicit, reviewed opt-out.
Implementation: directive + linter only¶
The directive declaration has no server-side code path. The
linter — run in CI on every PR — compares the current
schema against the base branch, computes the set of enum
values added on @final-marked enums, and fails the build
if the set is non-empty. No other tool in the production
stack observes the directive.
This is a specific instance of a broader pattern: schema linter enforcement where the directive is a marker and the linter is the gate.
The "ideal" — default-final¶
Zalando's post names an aspirational future: "The ideal situation would be that all enums are treated as final by default, and this directive is never required in the first place."
This mirrors how other language ecosystems handle the
parallel concern — Kotlin's classes and methods are
final by default and require explicit open to
subclass; Swift's final is likewise opt-out-of-default.
GraphQL's current default is the reverse: enums are
extensible without any annotation. @final is the opt-in
that flips the default locally.
Relationship with extensible enum¶
@extensibleEnum is the
counterpart when the value set is known to grow. The two
directives are not opposites — they mark different
lifecycle stages:
| Stage | Mechanism | Declaration |
|---|---|---|
| Value set will grow | Extensible enum | status: String! @extensibleEnum(values: [...]) |
| Value set is closed and stable | Final enum | enum Currency { USD @final EUR @final } |
| Value set may grow cautiously | Native enum, no annotation | enum Status { ACTIVE INACTIVE } |
A field can evolve through all three stages: start
extensible while the domain is new, migrate to native enum
as the set stabilises, add @final once the set is
closed.
Seen in¶
- Zalando UBFF — the canonical instance on the wiki.
@final on ENUM_VALUEwith linter-only enforcement, no runtime side-effect. Two-PR ritual documented as intentional friction (sources/2023-10-18-zalando-understanding-graphql-directives-practical-use-cases-at-zalando, systems/zalando-graphql-ubff).
Related¶
- concepts/extensible-enum — the counterpart marking growable value sets.
- concepts/schema-linter-enforcement — the enforcement
mechanism;
@finalhas no enforcement without it. - concepts/backward-compatibility · concepts/schema-evolution — the broader compatibility discipline.
- concepts/graphql-schema-directive — the primitive carrying the marker.
- systems/graphql · systems/zalando-graphql-ubff