A payroll CSV importer dispatches on typing.get_origin; why do Annotated columns fall through?
answer
- The metadata wrapper is on the outside
- Introspection reports the outermost layer first
- Argument zero is the wrapped type
- Strip before you dispatch, at every level
- Even an annotated scalar reports an origin
basics
~10 sBecause typing.get_origin(Annotated[list[Decimal], meta]) returns typing.Annotated, not list. The metadata wrapper is the outermost alias, so the importer's list and dict branches never match. Strip it first: typing.get_args returns the wrapped type at index 0.
solid answer
~40 s`Annotated[X, meta]` wraps `X` rather than replacing it, and it wraps it *outermost*, so introspection sees the wrapper first: `get_origin(Annotated[list[Decimal], 'gross_pay'])` is `typing.Annotated` and `get_args` returns `(list[Decimal], 'gross_pay')` — the wrapped type at index 0, every metadata object after it. A dispatcher that tests `origin is list` therefore skips annotated columns entirely, and a dispatcher that tests `origin is None` for scalars misfires too, since even `Annotated[int, 'hours']` reports an origin. The fix is a strip step run before every dispatch: while the origin is `Annotated`, replace the annotation with `get_args(ann)[0]`. It has to be applied at each level of recursion, not once at the top, because metadata can sit on an element type — `list[Annotated[Decimal, 'gross_pay']]` has a perfectly ordinary `list` origin and an annotated argument.
code
python · 16 linesfrom decimal import Decimal
from typing import Annotated, get_args, get_origin
def strip_metadata(ann):
while get_origin(ann) is Annotated:
ann = get_args(ann)[0]
return ann
GrossPay = Annotated[list[Decimal], "column=gross_pay"]
print(get_origin(GrossPay) is Annotated)
print(get_args(GrossPay))
print(strip_metadata(GrossPay))
print(get_origin(strip_metadata(GrossPay)))go deeper
Remember that metadata wraps the real type from the outside, so introspection sees the wrapper first and the wrapped type is the first entry of the argument tuple.
Explain why both the container branch and the scalar branch of a dispatcher miss annotated declarations, and write the loop that unwraps before any origin comparison happens.
Demonstrate the production judgement: unwrap at every recursion level, keep the metadata instead of throwing it away, select metadata by marker type, and have a test for each nesting shape your declarations allow.
Own the convention — which metadata classes your platform defines, who else is allowed to attach objects to the same declaration, and whether declaration-driven conversion is worth its debugging cost for the importers you run.
### What Annotated does to the object graph `typing.Annotated[X, *metadata]` exists so a declaration can carry information that the type checker ignores and your runtime code reads: a column name, a unit, a validation bound. Crucially it does not replace `X` — it wraps it. The resulting alias is a wrapper whose payload is `X` plus the metadata objects, and it sits *outside* whatever `X` was. So a payroll importer that declares a column as `Annotated[list[Decimal], 'column=gross_pay']` has a two-layer annotation: an `Annotated` wrapper on the outside, a `list[Decimal]` alias inside. Introspection reports the outermost layer, which is exactly why the dispatcher misses: ```python from typing import Annotated, get_args, get_origin GrossPay = Annotated[list[Decimal], 'column=gross_pay'] get_origin(GrossPay) # typing.Annotated -- not list get_args(GrossPay) # (list[Decimal], 'column=gross_pay') ``` Two branches break at once. The container branch (`origin is list`) never fires, because the origin is the wrapper. And the scalar branch, if it is written as `origin is None`, never fires for `Annotated[Decimal, 'net_pay']` either — that also reports `Annotated`, even though the thing being annotated is a plain class. So annotated columns fall past every case and land in whatever the function does with unknown shapes, which is usually a raise or, worse, a silent skip that leaves the field unset. ### The strip step The repair is small and belongs at the top of the dispatch function: ```python def strip_metadata(ann): while get_origin(ann) is Annotated: ann = get_args(ann)[0] return ann ``` The wrapped type is always argument zero; everything after it is metadata, in the order written. A `while` rather than an `if` costs nothing and is honest about the fact that you did not build the annotation and cannot promise how it was nested. ### Why the strip must happen at every level The subtle failure is stripping once, at the entry point, and assuming the rest of the tree is clean. Metadata can be attached anywhere: `list[Annotated[Decimal, 'gross_pay']]` reports origin `list` — no wrapper at the top at all — and only when the converter recurses into the element type does it meet the `Annotated`. If your recursion calls the strip on entry to each level, both shapes work; if it strips only at the top, the inner one raises when it tries to call an alias as a constructor. Test both nestings; a real declaration file will contain both within a week of anyone else using it. ### Nesting and flattening Direct nesting flattens: `Annotated[Annotated[int, 'a'], 'b']` normalizes at construction to a single wrapper carrying both metadata objects, so `get_args` returns `(int, 'a', 'b')`. That is convenient — one strip clears direct nesting — but it does not save you from the element-level case above, which is not direct nesting. Two other details matter in real code: `Annotated` requires at least one metadata argument, so there is no zero-metadata edge case to handle; and metadata objects are arbitrary values compared by equality, so two annotations with equal metadata are equal annotations, which is what lets you use them as registry keys. ### Reading the metadata you stripped Stripping throws the metadata away, and usually you want it — that is why the column was annotated. Take both in one pass: call `get_args` once, keep index 0 as the type and the remaining elements as the metadata, and hand the metadata to whatever decides the CSV header name or the rounding rule. Filter the metadata by `isinstance` against your own marker classes rather than by position; other libraries and other teams add their own objects to the same annotation, and positional assumptions break the moment a second consumer appears. ### Where this bites in an importer specifically A CSV importer takes strings and must decide what to construct: `Decimal` for money, `int` for hours, a list for a semicolon-joined multi-value column. All of that decision-making is origin dispatch, so every declaration style your team uses has to survive it. Annotated columns are the ones people add later — when someone needs a header alias or a currency tag — which is why the failure shows up in production against a file that used to import fine, rather than in the test that was written with plain annotations. ### Versions `typing.Annotated` is 3.9 and later (PEP 593); on 3.8 it lived in a third-party backport. `get_origin` reporting `Annotated` for a wrapped alias has been the behaviour since it was introduced and is unchanged on 3.14.
- How do you read the metadata rather than discard it, and how should a consumer pick out its own?Call `get_args` once: index 0 is the wrapped type, everything after it is metadata in written order. Select your own entries by `isinstance` against marker classes you define, never by position — other tools and other teams attach their objects to the same annotation, and a positional assumption breaks as soon as a second consumer exists.
- What happens with list[Annotated[Decimal, 'gross_pay']] as opposed to Annotated[list[Decimal], 'gross_pay']?The first reports origin `list` with no wrapper at the top, so a top-level-only strip leaves the `Annotated` sitting on the element type and the converter tries to call an alias as a constructor. The second reports `Annotated` outermost. Both are legitimate declarations, which is why the strip belongs at the entry of every recursion level.
- Does Annotated[Annotated[int, 'a'], 'b'] need repeated stripping?Directly nested wrappers flatten at construction into a single wrapper whose arguments are `(int, 'a', 'b')`, so one strip clears them. The loop is still worth writing: it costs nothing, and it documents that the annotation came from somewhere you do not control.
saying these in an interview costs you the question
- Expects get_origin to see through the metadata wrapper
- Thinks Annotated affects only static checking, not runtime objects
- Strips metadata once at the top and never during recursion
- Reads metadata by fixed position instead of by marker type
- Assumes an annotated scalar reports a None origin