skip to content

What does typing.NewType('VideoId', int) give you that the alias `VideoId = int` does not?

level: middleimportance: must knowfreq 50%

answer

  1. Aliases stop nothing at all
  2. A distinct type, checker only
  3. One-way: down yes, up no
  4. The call returns its argument unchanged
  5. No isinstance, no subclassing

basics

~20 s

typing.NewType creates a type a checker keeps distinct from its base: a plain int is rejected where a VideoId is expected. The alias VideoId = int is transparent and stops nothing. At runtime the call returns its argument unchanged.

solid answer

~50 s

An alias is transparent: `VideoId = int` means `int`, so mixing a video id with any other integer is undetectable. `VideoId = NewType('VideoId', int)` creates a **distinct static type**. The relationship is one-way -- a `VideoId` is accepted wherever an `int` is wanted, because it is treated as a subtype, but a bare `int` is rejected where a `VideoId` is required, so you must go through the explicit `VideoId(raw)` conversion at the boundary. That call is a no-op at runtime; it returns its argument unchanged, and since Python 3.10 `NewType` is implemented as a class, which makes the call cheap and the result picklable. Because no real type exists, you cannot use it in `isinstance` and you cannot subclass it -- both raise `TypeError`. It is the cheapest way to stop two same-shaped domain scalars, say a video id and a duration in milliseconds, from being swapped.

code

python · 13 lines
python
from typing import NewType

VideoId = NewType("VideoId", int)
DurationMs = NewType("DurationMs", int)


def fetch_metadata(vid: VideoId) -> dict[str, int]:
    return {"id": vid, "duration_ms": 0}


raw = 4271
print(fetch_metadata(VideoId(raw)))
print(VideoId(raw) == raw, type(VideoId(raw)))

go deeper

for a junior

Recall the headline: a plain alias is only a nickname, while typing.NewType makes a name the checker treats as its own type. Know that you convert explicitly with VideoId(value) and that nothing happens at runtime.

for a middle

Explain the asymmetry -- the derived type flows into int positions freely, a bare int never flows back without the explicit call -- and know that isinstance and subclassing both raise TypeError because no class exists.

for a senior

Show where you would place the conversions in a real pipeline: at parsing and at API boundaries, so the untyped integers are quarantined. Be candid that NewType is not validation, and pair it with an explicit parse step where data is untrusted.

for a principal

Own the decision of how much domain vocabulary to encode this way. Weigh the annotation churn and reviewer load against the class of bug it removes, and set the rule for when a scalar graduates from NewType to a real class with invariants.

### The problem it solves A media pipeline is full of integers that mean different things: a video id, a duration in milliseconds, a byte offset, a frame index. They are all `int`, so the checker cannot tell you that `fetch_metadata(duration_ms)` is a bug. Aliasing does not help, because `VideoId = int` is transparent -- it expands back to `int` everywhere and every one of those integers remains mutually assignable. `typing.NewType` is the minimal tool for that problem: ```python from typing import NewType VideoId = NewType("VideoId", int) DurationMs = NewType("DurationMs", int) ``` Now `fetch_metadata(vid: VideoId)` rejects a `DurationMs`, rejects a bare `int`, and accepts only a value that someone explicitly converted with `VideoId(...)`. ### The asymmetry is the whole design The derived type is treated as a **subtype** of its base. That gives a deliberate one-way street: * `int` -> `VideoId`: **rejected**. You must write `VideoId(raw)`, which is the point -- the conversion is a visible, greppable boundary where an untrusted integer becomes a domain value. * `VideoId` -> `int`: **accepted**. Arithmetic, formatting and any function taking `int` keep working, so adopting NewType in an existing codebase does not force you to rewrite everything downstream. That asymmetry is why NewType is cheap to introduce incrementally: annotate the producer and the consumer, and the checker finds the mismatched call sites for you. ### What happens at runtime Almost nothing, by design. `VideoId(4271)` returns exactly the object you passed -- it is an identity function -- so there is no wrapper, no allocation and no attribute lookup penalty on the value itself. `type(VideoId(4271))` is `int`. Two consequences follow, and interviewers like both: * **`isinstance(x, VideoId)` raises `TypeError`.** There is no class to test against. If you need a runtime check, test the base type and accept that runtime cannot distinguish the two. * **You cannot subclass it.** `class Sub(VideoId): ...` raises `TypeError`; the error message suggests the intended spelling instead, which is to layer another NewType on top: `AdminVideoId = NewType("AdminVideoId", VideoId)`. Layering is supported and the subtype chain is respected by the checker. Since **Python 3.10**, `NewType` is a class rather than a function returning a closure. The visible effects are that the call is faster, the result pickles, and the object supports the `|` union spelling. The semantics did not change: it is still purely a static-analysis device. ### When to reach for it, and when not to Reach for NewType when the value is a **scalar with no behaviour of its own** and the risk is confusion with other values of the same shape: ids, opaque tokens, currency minor units, indices into a specific array. It costs one line, nothing at runtime, and no change to how the value is used. Do not reach for it when: * You need **runtime validation** -- NewType checks nothing; `VideoId("not an int")` happily returns the string, and only the checker complains. If a value crosses a trust boundary, parse and validate it explicitly, then wrap. * You need **behaviour** -- methods, a normalised representation, an invariant enforced in one place. That is a small class, a frozen dataclass, or a `str`/`int` subclass, all of which do exist at runtime and all of which cost more. * You only wanted a **shorter name** for a long type. That is a plain alias. ### The three-way comparison to have ready Asked to distinguish the family, answer along two axes -- does it exist at runtime, and does the checker keep it distinct: | tool | exists at runtime | distinct to the checker | | --- | --- | --- | | alias (`VideoId = int`) | no new object | no, fully transparent | | `NewType("VideoId", int)` | a factory that returns its argument | yes, one-way | | `class VideoId(int)` | yes, a real subclass | yes, and `isinstance` works | A candidate who can place all three, and who names the explicit conversion at the boundary as the feature rather than the friction, has answered it well.

  • What does calling `VideoId(4271)` actually cost at runtime?
    Effectively nothing beyond one call: it returns the argument unchanged, so there is no wrapper object and no extra memory per value. Since 3.10 `NewType` is a class whose call is a thin identity, which is cheaper than the closure it replaced. If even that call is unwanted in a hot loop, convert once at the boundary where data enters and pass the already-converted value onward.
  • Can you subclass a NewType, or build one on top of another?
    Subclassing raises `TypeError` -- there is no class to inherit from, and the error message points you at the intended spelling. Layering works: `AdminVideoId = NewType('AdminVideoId', VideoId)` is legal, and the checker treats it as a subtype of `VideoId`, which is in turn a subtype of `int`. Values still flow upward freely and never downward without an explicit call.
  • When would a frozen dataclass or an `int` subclass beat NewType for a domain scalar?
    When you need something at runtime. NewType validates nothing, so an invalid value that was never checked stays invalid, and no code can test the distinction with `isinstance`. If the value needs an invariant enforced in one place, a normalised representation, methods, or a runtime-visible tag for dispatch or logging, use a real class and accept the allocation and attribute-lookup cost.

saying these in an interview costs you the question

  • Claims NewType creates a real class you can isinstance
  • Says it validates the value passed to it
  • Thinks it is just a nicer alias with no static effect
  • Believes an int is accepted where a NewType is required
  • Assumes it wraps the value in an object at runtime
  • Tries to subclass it to add behaviour

context