skip to content

When would you annotate a parameter typing.SupportsIndex instead of int?

level: seniorimportance: nice to knowfreq 20%

answer

  1. Structural, not nominal
  2. The dunder for exact integers
  3. Indexing, slicing, hex and bin use it
  4. numbers ABCs rely on runtime registration
  5. operator.index refuses to truncate

basics

~20 s

typing.SupportsIndex describes any object with a lossless index method, which Python requires wherever a true integer is needed, such as sequence indexing or hex(). Annotate it in library code that forwards the value into such an operation rather than doing arithmetic on it.

solid answer

~40 s

`typing.SupportsIndex` is a structural protocol matched by anything defining `__index__`, the method Python calls when it needs an **exact** integer — indexing, slicing, `hex()`, `bin()`, `operator.index()`. Annotating it says "give me something integer-like that I will hand straight to an integer operation", so wrappers such as an enum-like identifier or a custom fixed-width integer are accepted without forcing callers to convert. Contrast `__int__`, which may truncate: `int()` is allowed to be lossy, `__index__` is not. `typing.SupportsFloat` is the analogous `__float__` protocol. The `numbers` ABCs look like the right tool and are not: `numbers.Integral` gets its members through runtime `register()` calls that no static checker follows, so annotating `numbers.Real` fails to accept a plain `float`. For ordinary application code `int` remains the right annotation; the protocols earn their place at library boundaries.

code

python · 18 lines
python
import operator
from typing import SupportsIndex


class ChannelId:
    def __init__(self, slot: int) -> None:
        self.slot = slot

    def __index__(self) -> int:
        return self.slot


def sample(row: list[str], where: SupportsIndex) -> str:
    return row[where]


print(sample(["t0", "t1", "t2"], ChannelId(2)))
print(operator.index(ChannelId(2)), hex(ChannelId(255)))

go deeper

for a junior

Know that int is the normal annotation for whole numbers and that typing contains small protocols named after what an object supports. Recognising SupportsIndex in a signature is enough at this level.

for a middle

Explain that SupportsIndex is structural, matched by any class defining index, and that this dunder is what Python calls for indexing, slicing and hex, unlike the possibly lossy int behind int().

for a senior

Show the judgement: annotate the protocol only at library boundaries where integer-like wrappers are forwarded into indexing, and explain why the numbers ABCs are unusable statically because their membership comes from runtime registration.

for a principal

Own the vocabulary your codebase uses for numeric parameters — where protocols are allowed, how exact types such as Decimal cross module boundaries, and the cost in reader comprehension and error-message quality of accepting duck-typed integers.

## Two different questions hide behind "annotate this as a number" When a parameter should accept "an integer-ish thing", Python offers three spellings, and only two of them work statically. ### 1. `int` — the default, and usually correct Most code should annotate `int`. Thanks to `bool` being a subclass it already accepts booleans, and thanks to PEP 484's promotion an `int` is accepted onwards where `float` is annotated. Application code that does arithmetic wants exactly this. ### 2. `typing.SupportsIndex` — the structural integer `SupportsIndex` is a protocol in `typing`, satisfied by any class defining `__index__`. That dunder was introduced by PEP 357 to answer a specific question: what may be used as a sequence index? The contract is that `__index__` returns an `int` **without loss** — it is not permitted to round or truncate. Python calls it in every place that demands an exact integer: ```python import operator from typing import SupportsIndex class ChannelId: def __init__(self, slot: int) -> None: self.slot = slot def __index__(self) -> int: return self.slot row = ["t0", "t1", "t2"] print(row[ChannelId(2)]) # t2 print(operator.index(ChannelId(2))) print(hex(ChannelId(255))) # 0xff ``` The same object also works in a slice, in `range()`, and as a `bytes` length. Note that `int(x)` is the *lossy* door: it will use `__int__` if present, and `__int__` is allowed to truncate — which is why `operator.index()` exists as the strict alternative. Annotating `SupportsIndex` is a promise about what you will do with the value: hand it to an integer operation, not multiply it. In a sensor-telemetry collector this is exactly the shape that appears. Device channels are wrapped in a small identifier class so they cannot be confused with sample counts, yet the wrapper still has to index a fixed row of readings. Giving it `__index__` and annotating the helper `SupportsIndex` keeps the wrapper usable without either an `int()` sprinkle at every call site or an annotation that lies. `typing.SupportsFloat` is the float-side counterpart, matched by anything with `__float__`; `typing.SupportsInt`, `typing.SupportsComplex`, `typing.SupportsAbs` and `typing.SupportsRound` complete the small family. `SupportsFloat` is used far less than `SupportsIndex`, because the numeric-tower promotion already covers the common case and `__float__` may be lossy. ### 3. The `numbers` ABCs — the trap PEP 3141 gave Python an abstract numeric tower: `numbers.Number`, `numbers.Complex`, `numbers.Real`, `numbers.Rational`, `numbers.Integral`. At runtime it works through **registration**: ```python import numbers from decimal import Decimal print(isinstance(3.0, numbers.Real)) # True print(float in numbers.Real.__mro__) # False - registered, not inherited print(isinstance(Decimal("1.5"), numbers.Number)) # True ``` `float` is not a subclass of `numbers.Real`; it was *registered* as one by a call executed when the module is imported. Static checkers do not execute your program, and they deliberately do not follow `register()`, so as an annotation `numbers.Real` behaves as an unrelated class: passing a plain `float` to a parameter annotated `numbers.Real` is reported as an error. PEP 484 acknowledged this directly, which is why it introduced the `int`/`float`/`complex` promotion instead of telling everyone to import `numbers`. **`numbers` remains useful for `isinstance` checks at runtime; it is not an annotation vocabulary.** ## What the protocol promises, and what it does not `SupportsIndex` guarantees exactly one thing: `__index__` exists and yields an integer. It says nothing about arithmetic, ordering or formatting, so a body that writes `where + 1` is asking for more than the annotation provides and should annotate `int` instead. The contract is also enforced at runtime rather than merely documented: if `__index__` returns anything other than an `int`, the operation fails with `TypeError: __index__ returned non-int (type float)` rather than silently rounding. The same door is used by `range()`, by slice bounds, by `bytes(n)` and by the bit-shift operators, which is why a wrapper that defines `__index__` becomes usable across all of them at once — and why defining it on a type that is *not* conceptually an exact integer is a mistake. ## The judgement call Protocols are not free. `SupportsIndex` in a signature is one more concept for a reader, it accepts objects your error messages may not have been written for, and it does nothing for a body that performs arithmetic — an object with `__index__` is not required to support `+`. Use it when three things hold: the parameter is genuinely a *position or exact integer*, the value is forwarded into an indexing or bit-level operation, and the API is consumed by callers you do not control. Inside one application, `int` is clearer and the conversion belongs at the edge. The last piece of judgement is precision types. `decimal.Decimal` satisfies neither `int` nor `float` statically, and that is deliberate — mixing it with binary floats loses the exactness you adopted it for. Model such an API with an explicit union or an overload rather than reaching for a numeric ABC that no checker understands.

  • What is the difference between __index__ and __int__?
    `__index__` must return an exact integer with no loss, and Python calls it wherever a true integer is required — indexing, slicing, `range()`, `hex()`, `bin()`, `operator.index()`. `__int__` backs `int(x)` and is permitted to be lossy, as truncation of a real number is. A type that is conceptually an integer should define `__index__`; one that merely knows how to produce an integer approximation should define only `__int__`.
  • Why does annotating a parameter numbers.Real fail to accept a plain float?
    Membership in the `numbers` ABCs comes from `register()` calls executed at import time, not from inheritance — `float` is registered with `numbers.Real` but does not appear in its MRO. Static checkers analyse source without running it and do not model dynamic registration, so they treat `numbers.Real` as an unrelated class and reject the argument. The ABCs remain fine for runtime `isinstance` checks; they are simply not an annotation vocabulary.
  • Should application code prefer SupportsIndex over int as a habit?
    No. `int` is clearer, matches almost every real parameter, and already accepts `bool`. `SupportsIndex` earns its place at a library boundary where callers you do not control may pass integer-like wrappers, and where the body forwards the value into an indexing or bit-level operation rather than doing arithmetic on it. Used everywhere it becomes noise, and it does not guarantee the object supports `+` at all.

saying these in an interview costs you the question

  • Annotating numbers.Real or numbers.Integral for static typing
  • Believing checkers follow ABC register() calls
  • Treating SupportsIndex and SupportsInt as interchangeable
  • Assuming __index__ may truncate like int() does
  • Claiming an object with __index__ also supports arithmetic
  • Reaching for a protocol where a plain int annotation suffices

context