Skip to content

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:

  1. 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.
  2. 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

directive @final on ENUM_VALUE

enum Currency {
  USD @final
  EUR @final
  GBP @final
}

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

Last updated · 501 distilled / 1,218 read