When would you expose a parameter as typing.Literal[...] rather than as an enum.Enum in a public API?
answer
- Who pays — the caller or the maintainer?
- Strings already at the boundary
- Somewhere to hang behaviour and metadata
- Only one of the two validates at runtime
- Union at the edge, enum inside
basics
~20 sChoose a literal union when the values are already plain data at the boundary — config keys, CLI arguments, JSON fields — and callers should not have to import anything. Choose an enum when the values need a canonical home, runtime validation, or behaviour attached.
solid answer
~50 sA literal union costs the caller nothing: they pass `"read"`, no import, no conversion, and the value they already have from a config file or a request body fits the signature directly. That is its whole advantage, and at an outer boundary it is a large one. An enum buys the things a bare string cannot: a single definition to rename, a runtime lookup that rejects an unknown value with `ValueError`, methods and metadata hanging off each member, and discoverability in an editor. The cost is an import in every caller and a conversion step at every serialisation boundary — softened but not removed by `StrEnum` since 3.11. In practice the useful shape is both: accept the literal union at the public edge, convert once to the enum on the way in, and use the enum internally where behaviour and invariants live.
code
python · 13 linesfrom enum import StrEnum
from typing import Literal
Mode = Literal["read", "write"]
class Phase(StrEnum):
ALIGN = "align"
CALL = "call"
def run_stage(mode: Mode, phase: Phase) -> str:
return f"{mode}/{phase.value}"
print(run_stage("read", Phase.ALIGN))go deeper
Know that both spell a closed set of allowed values, and that a literal union is a bare string the caller passes while an enum is a class the caller must import first.
Explain the concrete tradeoffs: import cost, one definition to rename versus strings repeated at each use, and the fact that only the enum can validate an incoming value at runtime.
Show the boundary design — accept the union at the public edge, convert once, use the enum internally — and name where the explicit runtime check has to live for data arriving from outside.
Own the migration and consistency story: how a published signature moves from one to the other without breaking callers, and what house rule keeps a large codebase from carrying both conventions at random.
## Two ways to spell a closed set Both constructs answer the same question — "this parameter has three legal values" — and a checker can reason about either. The decision is an API-design one, and it turns on who pays. ```python Mode = Literal["read", "write"] # callers pass a bare string class Phase(StrEnum): # callers import and pass Phase.ALIGN ALIGN = "align" CALL = "call" ``` ## What a literal union gives you **Zero import cost for callers.** The parameter accepts data the caller already has. A genome- annotation pipeline whose stage names arrive from a YAML config, a CLI argument or a job payload holds strings; with a literal union those strings flow straight into the call. With an enum, every one of those boundaries needs a conversion. **A self-documenting signature.** `mode: Literal["read", "write", "append"]` states the whole domain in the place a reader is already looking, with no jump to a definition. **Nothing to version.** There is no class to import, so there is no import cycle, no question of which module owns it, and no mismatch when two copies of a package end up loaded. **Cheap introspection.** `typing.get_args` on the alias recovers the tuple of values, which is enough to generate CLI choices or a validator from the same declaration. ## What an enum gives you **One canonical definition.** Rename a value in one place instead of grepping for a string repeated across a dozen signatures and a hundred call sites. This is the argument that grows with the codebase. **Runtime validation.** Calling the enum with a value that is not a member raises `ValueError`. A literal union has no runtime existence at all, so an unvalidated string from JSON sails past every annotation in the process. **Somewhere to attach things.** Members carry methods, properties and metadata: a per-mode timeout, a default budget, a display label. A literal union has nowhere to hang any of that, and the logic ends up as a dict keyed by the string, defined somewhere else, easy to leave incomplete. **Discoverability.** Typing `Phase.` in an editor lists the options; discovering the legal strings requires reading the signature. ## The cost of the enum, honestly Callers must import your class, which couples them to your module layout. Every serialisation boundary needs conversion in both directions — `StrEnum` (3.11) makes members usable directly as strings and eases the outbound half, but you still need a lookup inbound. And identity comparisons break in the awkward cases: two copies of a module loaded under different names, or a value that crossed a process boundary and came back, produce members that are not the same object. ## The shape that usually wins Take the union at the edge and the enum inside: ```python def run_stage(phase: Phase | Literal["align", "call"]) -> None: phase = Phase(phase) # one validating conversion at the boundary ... ``` Callers who already have the enum pass it; callers holding a config string pass that; the body deals with exactly one type. The conversion is the runtime validation the annotation could never provide. ## Rules of thumb Reach for a **literal union** when the set is small and stable, the values are already strings in the surrounding system, nothing needs to hang off them, and the parameter sits at an outer boundary where imports are a tax on your users. Two or three modes on a handful of functions is exactly this case. Reach for an **enum** when the set is used in many places, is likely to be renamed or extended, needs per-member behaviour or metadata, or must be validated at runtime because the value comes from outside the type system. Anything that becomes a domain concept in its own right — a state, a phase, a severity — has outgrown a literal union. The judgement an interviewer is listening for is that neither choice provides runtime safety by itself: the literal union provides none, and the enum provides it only at the moment you call the class on an incoming value. Wherever untyped data enters, one explicit conversion or check has to exist, and the type declaration merely tells you where to put it. ## Generating the rest from the declaration Whichever you pick, the declaration should be the single source for everything derived from it. `typing.get_args` on a named literal alias yields the tuple of legal values, so CLI choices, documentation tables and a validation helper can be generated from the same alias the checker reads — which removes the most common decay mode, where the annotation gains a value and the hand-written validator does not. An enum gives you the same thing by iterating the class, plus a natural place to record what each value *means*. The failure to avoid is a third copy: an annotation listing three values, a validator listing four, and a documentation string listing two. That divergence is what actually bites in production, and it is independent of which construct you chose — so whichever you pick, derive rather than repeat.
- A value arrives as a string from a JSON payload. What does a Literal annotation on the parameter guarantee about it?Nothing at runtime. The annotation constrains what a checker will let *typed* code pass; deserialised data enters as a plain `str` and reaches the function body unexamined. A checker will in fact flag passing that `str` where the literal union is required, which is the useful part — it forces you to put an explicit validation step at the boundary rather than pretending the annotation was one.
- How would you migrate a published API from a literal union to an enum without breaking callers?Widen the parameter to accept both — `Phase | Literal["align", "call"]` — and normalise with a single conversion at the top of the function. Existing string callers keep working and type-check, new callers get the enum, and the body only ever sees one type. Then narrow the annotation to the enum alone once the callers you care about have moved.
- What does an enum offer that a literal union structurally cannot?A place to attach behaviour and data. Members can carry methods, properties and metadata — a per-member timeout, a display label, a default budget — so the logic that varies by value lives next to the value. With a literal union that logic becomes a separate dict keyed by the string, defined elsewhere, with nothing tying the two together or flagging a missing key.
saying these in an interview costs you the question
- Claiming a Literal annotation validates incoming JSON
- Choosing an enum purely because it looks more object-oriented
- Ignoring the import cost a public enum puts on callers
- Repeating the same literal strings across dozens of signatures
- Assuming enum members compare identically across process boundaries