20 — Type Hints & Typing
Q1. What happens when the following code is run with plain CPython (no external tool)?
def add(a: int, b: int) -> int:
return a + b
print(add("3", "4"))
- Raises a
TypeErrorbecause"3"is not anint - Raises a
TypeErrorbecause the return value doesn't matchint - Prints
"34"with no error at all - Raises a
SyntaxErrorat import time because the annotations are violated
Show Answer
Answer: C — Prints "34" with no error at all
Explanation: Safety: Python's type hints are not enforced at runtime by the interpreter. int on a and b is purely documentation/metadata (stored in __annotations__) unless a separate tool checks it. Since str + str is valid Python, add("3", "4") just concatenates and returns "34". The tempting answers assume CPython behaves like a statically typed language and raises on mismatched types — it does not; only static checkers like mypy or pyright, or runtime validators like pydantic, would flag this.
Q2. Which two type hints are exactly equivalent?
from typing import Optional, Union
def f(x: Optional[str]) -> None: ...
def g(x: Union[str, None]) -> None: ...
- They are equivalent —
Optional[str]is defined asUnion[str, None] - They are different —
Optional[str]also allows omitting the argument entirely - They are different —
Union[str, None]allowsinttoo, by widening - They are equivalent only under Python 3.11+
Show Answer
Answer: A — They are equivalent — Optional[str] is defined as Union[str, None]
Explanation: Optional[X] is literally shorthand in the typing module for Union[X, None] — nothing more. A very common misconception (option B) is that Optional makes an argument optional in the sense of having a default value; it does not — you still must pass something explicitly unless you separately give the parameter a default like = None. This equivalence has held since Optional was introduced and is not a 3.11 change.
Q3. What is the key practical difference between annotating a parameter as Any versus object?
from typing import Any
def handle_any(x: Any) -> None:
x.whatever_method()
def handle_object(x: object) -> None:
x.whatever_method()
- There is no difference — both disable all static type checking on
x -
objectdisables checking,Anyis checked strictly -
Anytells the checker to skip checking onxentirely;objectis a real type, so calling an arbitrary method on it is a static type error -
Anyandobjectboth restrictxto have no methods at all
Show Answer
Answer: C — Any tells the checker to skip checking on x entirely; object is a real type, so calling an arbitrary method on it is a static type error
Explanation: Idiom: object is the actual root of Python's type hierarchy — every value is an object, but a static checker only knows about the methods object itself defines (__eq__, __repr__, etc.), so handle_object would fail a mypy check on x.whatever_method(). Any is special-cased by type checkers to be compatible with everything in both directions, effectively opting x out of static checking. Beginners often assume object is the "accept anything, no checks" type since it's the base of everything — that's actually what Any is for.
Q4. What does this generic function signature guarantee to a static type checker?
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
result = first([1, 2, 3])
-
resultis inferred asAnybecauseTypeVarcan't track concrete types -
resultis inferred asint, because the checker bindsTtointfrom the call site -
resultis inferred aslist[int] -
TypeVarforces a runtime check that all list items share the same type
Show Answer
Answer: B — result is inferred as int, because the checker binds T to int from the call site
Explanation: A TypeVar lets a static checker propagate a concrete type through a generic function: given list[int], T is bound to int for that call, so the checker infers first(...) returns int. Nothing about this is enforced by CPython at runtime (option D is wrong — no runtime check ever happens); TypeVar is a pure static-analysis construct. Option A is the common mistake of assuming generics degrade to Any, when in fact the whole point of TypeVar is to preserve the specific type through the call.
Q5. Which statement correctly describes how mypy relates to running your program?
-
mypyis a runtime import that Python executes automatically beforemain() -
mypyis a separate static-analysis tool you run against your source files; it reports type errors but does not change how the code executes -
mypypatches the CPython interpreter to raiseTypeErroron hint violations -
mypyand type hints are required for Python code to run at all
Show Answer
Answer: B — mypy is a separate static-analysis tool you run against your source files; it reports type errors but does not change how the code executes
Explanation: mypy (like pyright) reads your source, checks annotations for consistency, and reports diagnostics — completely separately from python script.py. It never modifies runtime behavior or the interpreter. Options A, C, and D describe a form of enforcement Python simply does not have out of the box; type hints are always optional documentation to CPython itself.
Q6. In modern Python (3.9+), which is the recommended way to annotate "a list of integers" without importing anything extra?
-
List[int]fromtyping, always -
list[int], using the built-inlistdirectly as a generic -
list(int) -
list<int>
Show Answer
Answer: B — list[int], using the built-in list directly as a generic
Explanation: Idiom: PEP 585 (Python 3.9+) made the built-in collection types (list, dict, set, tuple, etc.) directly subscriptable for annotations, so list[int] works without importing typing.List. typing.List[int] (option A) still works for backward compatibility but is now considered legacy style. list(int) (option C) is actually a runtime call to the list constructor with int as an argument — not an annotation at all, and it would raise TypeError if actually executed since int isn't iterable.
Q7. What does Callable[[int, str], bool] describe?
- A callable that takes any two positional arguments and returns either an
int,str, orbool - A callable taking exactly one
intargument and onestrargument (positionally) and returning abool - A tuple of three types:
int,str, andbool - A callable that takes an
intorstrand returnsbool
Show Answer
Answer: B — A callable taking exactly one int argument and one str argument (positionally) and returning a bool
Explanation: Callable[[ArgTypes...], ReturnType] uses a list of the parameter types (positional-only, in order) as the first element and the return type as the second. So Callable[[int, str], bool] means "call it with (some_int, some_str) and get a bool back." Option A misreads the union-like syntax that doesn't apply here; Callable's argument list is positional and ordered, not a set of alternatives.
Q8. A class needs to reference its own type in a method signature before the class body finishes executing. Which approach avoids a NameError at class-definition time on Python versions before 3.10 without from __future__ import annotations?
class Node:
def __init__(self, value: int) -> None:
self.value = value
self.next: "Node" | None = None
- It will always raise
NameErrorregardless of quoting - Quoting the type as a string forward reference (
"Node") defers evaluation so it works even thoughNodeisn't fully defined yet - You must define
Nodetwice — once as a stub, once for real - Forward references only work inside
@dataclassclasses
Show Answer
Answer: B — Quoting the type as a string forward reference ("Node") defers evaluation so it works even though Node isn't fully defined yet
Explanation: Debug: Annotations are normally evaluated at function-definition time by default in older Python, so referencing the enclosing class by name before its class statement completes would fail. Wrapping the reference in quotes makes it a forward reference — a string that a type checker parses lazily instead of the interpreter evaluating it immediately. Note the snippet mixes syntaxes for illustration (the "Node" | None union-with-string form needs care), but the core mechanism being tested is that string annotations sidestep the ordering problem, unlike option A's assumption that it always fails.
Q9. Why would a codebase use if TYPE_CHECKING: around an import?
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from mymodule import HeavyClass
def process(item: "HeavyClass") -> None: ...
- To make the import faster by caching it
-
TYPE_CHECKINGis alwaysTrue, so this is identical to a normal import -
TYPE_CHECKINGisFalseat runtime, so the import (which might cause a circular import or unnecessary runtime cost) is skipped when the program actually executes, while static checkers still see it and validate the annotation - It silences all mypy errors in the module
Show Answer
Answer: C — TYPE_CHECKING is False at runtime, so the import is skipped when the program executes, while static checkers still see it and validate the annotation
Explanation: typing.TYPE_CHECKING is False during normal interpretation and treated as True by static analyzers like mypy. This lets you import a class purely for annotation purposes — avoiding a circular import or a heavy/optional dependency at runtime — while the checker still resolves the forward-referenced type string. Assuming it's always True (option B) would mean the import always executes, defeating the entire purpose of the pattern.
Q10. What does UserId = NewType("UserId", int) actually produce at runtime?
from typing import NewType
UserId = NewType("UserId", int)
uid = UserId(42)
print(type(uid), uid + 1)
- A new subclass of
intwithUserIdas its runtime type - A callable that at runtime just returns its argument unchanged —
type(uid)isint, anduid + 1works as plain int arithmetic; the distinctness only exists for static checkers - A
TypeError, becauseNewTyperequires a class, not a builtin - A dataclass wrapping the int with a
.valueattribute
Show Answer
Answer: B — A callable that at runtime just returns its argument unchanged; the distinctness only exists for static checkers
Explanation: Idiom: NewType creates a lightweight identity function at runtime — calling UserId(42) just returns 42, and type(uid) prints <class 'int'>, not some special UserId type. Its entire purpose is to let a static checker treat UserId and plain int as distinct (so you can't accidentally pass a raw int where a UserId is expected) without any runtime wrapping cost. Believing it creates a real subclass (option A) is the most common misunderstanding — that's what subclassing int directly would do, not NewType.
Q11. What runtime guarantee does a TypedDict provide?
from typing import TypedDict
class Movie(TypedDict):
title: str
year: int
m: Movie = {"title": "Arrival", "year": "2016"}
print(m)
- None — at runtime
mis a plaindict; the"year": "2016"string instead ofintwill not raise anything, and this only shows up as an error under a static checker - A
TypeErroris raised immediately becauseyearshould be anint -
TypedDictautomatically coerces"2016"to2016 - Missing required keys raise a
KeyErrorat construction time
Show Answer
Answer: A — None — at runtime m is a plain dict; the string year does not raise, and only a static checker flags it
Explanation: TypedDict exists purely to give static checkers shape information about a dict's expected keys and value types; at runtime, Movie instances are ordinary dict objects with zero validation, coercion, or enforcement. Both the type mismatch ("2016" vs int) and even a missing required key would run without error — only tools like mypy catch them. For actual runtime validation, you'd reach for something like pydantic or manual checks, not TypedDict.
Q12. What happens if you call a function decorated with multiple @overload signatures using arguments that don't match any of the declared overloads, when running the actual script (not a type checker)?
from typing import overload
@overload
def process(x: int) -> int: ...
@overload
def process(x: str) -> str: ...
def process(x):
return x
process(3.14)
- Raises
TypeErrorimmediately becausefloatmatches no overload - Runs fine at runtime — only the final, un-decorated implementation actually executes, and
@overloadstubs are ignored by the interpreter; only a static checker would flag thefloatargument as invalid - Silently returns
Nonebecause no overload matched - The
@overloaddecorator dispatches to the closest matching stub automatically
Show Answer
Answer: B — Runs fine at runtime; @overload stubs are ignored by the interpreter and only the real implementation executes
Explanation: Debug: @overload-decorated stub bodies (the ... ones) are never actually called — Python discards them, and only the final non-decorated process(x): return x implementation runs. @overload exists solely so a static checker can offer precise per-signature type checking and autocompletion; there is no runtime dispatch mechanism at all (option D is a common false assumption — Python has no built-in multiple dispatch). Since the concrete implementation accepts anything, process(3.14) just returns 3.14 with no error.
Q13. A function is annotated def get_ids() -> list[int]: but its body is return []. Is this a problem at runtime?
- Yes — an empty list can never satisfy
list[int], so this raisesTypeErroron return - No — an empty list contains no elements to contradict
int, and since hints aren't enforced at runtime anyway, this runs without any error regardless - Yes, but only in Python 3.12+
- No, but it silently converts to
[0]
Show Answer
Answer: B — No; an empty list vacuously satisfies the hint, and runtime never checks it anyway
Explanation: Two separate reasons both point to "no error": logically, list[int] is satisfied vacuously by an empty list since there's no element violating the constraint, and practically, CPython never inspects the return annotation at runtime regardless. Even returning ["not", "an", "int"] from this function would run without any exception — the mismatch would only surface as a static type error under mypy.
Q14. What is the correct, type-hint-safe way to give a dataclass field a mutable default value?
from dataclasses import dataclass, field
@dataclass
class Config:
tags: list[str] = field(default_factory=list)
-
tags: list[str] = []— dataclasses handle mutable defaults safely, unlike plain functions - The shown
field(default_factory=list)form — a bare mutable literal default (= []) raisesValueErrorat class-definition time, anddefault_factorycalls the factory fresh for each new instance -
tags: list[str] = Noneand check forNonein__post_init__ -
tags: List[str] = listwithout calling it
Show Answer
Answer: B — field(default_factory=list); a bare = [] default raises ValueError at class-definition time
Explanation: Safety: @dataclass explicitly detects a mutable literal default (list, dict, set) for a field and raises ValueError: mutable default <class 'list'> for field tags is not allowed at class-definition time — dataclasses deliberately guard against the classic Python mutable-default-argument footgun rather than silently sharing one list across instances. field(default_factory=list) tells it to call list() fresh per instance instead. Option A is the tempting mistake because dataclasses look like they might "fix" the mutable-default problem automatically — they instead refuse to let you make it.
Q15. What is the idiomatic reason to prefer Optional[str] = None over just writing str = None for a parameter default?
def greet(name: str = None) -> None: ...
def greet2(name: Optional[str] = None) -> None: ...
-
str = Noneis aSyntaxError - They behave identically at runtime, but
str = Noneis a lie to static checkers —Noneisn't astr, so mypy flags it, whileOptional[str] = Noneaccurately documents thatNoneis a valid value -
Optional[str]makes the parameter itself optional to omit, whilestr = Nonedoes not -
Optional[str] = Noneis slower because it wraps the value in a union object at call time
Show Answer
Answer: B — They behave identically at runtime; str = None is inaccurate to static checkers, while Optional[str] documents that None is valid
Explanation: Idiom: Both run identically since CPython ignores hints, but a strict type checker treats name: str = None as an error because None is not a str and the annotation says nothing about it being optional. Optional[str] correctly communicates "this can be a str or None" to both the checker and human readers. Runtime "optionality" (whether you can omit the argument) comes purely from the = None default, not from Optional — that's a separate, commonly conflated concept (option C).
Q16. When should you prefer typing.Protocol over an abstract base class (abc.ABC) for defining an interface?
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
def cleanup(resource: SupportsClose) -> None:
resource.close()
- Never —
Protocolis strictly worse and slower thanABC - When you want structural ("duck") typing — any object with a matching
close()method satisfies the type statically without explicitly inheriting fromSupportsClose, unlikeABCwhich requires nominal subclassing -
Protocolrequires runtime registration viaregister(), same asABC -
Protocolcan only be used for dataclasses
Show Answer
Answer: B — Use Protocol for structural typing; any object with a matching method satisfies it without explicit inheritance, unlike ABC's nominal typing
Explanation: Idiom: Protocol implements structural subtyping (PEP 544) — a static checker considers any class that happens to implement the right methods/attributes as compatible, with zero inheritance relationship required. ABC requires explicit class Foo(SupportsClose): nominal subclassing (or register()) to be recognized. This makes Protocol a good fit for third-party classes you can't modify to add a base class, which is a common real-world constraint ABC can't satisfy as cleanly.
Q17. What does marking a variable with Final actually do?
from typing import Final
MAX_RETRIES: Final = 3
MAX_RETRIES = 5
- It raises
TypeErrorimmediately on the reassignment line - It is purely a static-analysis hint — mypy will flag the reassignment as an error, but CPython executes the second line without complaint and
MAX_RETRIESbecomes5 - It makes the name read-only via the C-level immutability flag
-
Finalonly works inside classes, not at module level
Show Answer
Answer: B — It's purely a static-analysis hint; CPython executes the reassignment fine and MAX_RETRIES becomes 5
Explanation: Safety: Like all typing constructs, Final carries zero runtime enforcement — it tells a checker "treat this as a constant; flag any rebinding," but the interpreter has no concept of a Final-protected name and simply rebinds it. Developers coming from languages with real const/final keywords (option C) often expect actual immutability enforcement, which Python's typing system deliberately does not provide — runtime constant-protection would need a different mechanism entirely (e.g., a custom descriptor or just convention).
Q18. What is the purpose of ClassVar in this dataclass?
from typing import ClassVar
from dataclasses import dataclass
@dataclass
class Counter:
count: int = 0
total_instances: ClassVar[int] = 0
- It marks
total_instancesas a per-instance field with default0, identical tocount - It tells both static checkers and
@dataclassitself to treattotal_instancesas a class-level attribute, excluding it from the generated__init__and instance fields — unlikecount, which becomes a normal__init__parameter - It makes
total_instancesthread-safe automatically -
ClassVarhas no effect on@dataclass's generated code; it's purely cosmetic
Show Answer
Answer: B — It marks total_instances as class-level, excluded from the generated __init__ and instance fields
Explanation: Idiom: @dataclass specifically inspects annotations for ClassVar and, unlike ordinary fields, excludes those attributes from the auto-generated __init__, __repr__, and __eq__ — they remain shared class attributes, exactly like a normal class-body assignment without a dataclass. This is one of the few typing constructs that a real library (dataclasses) actually inspects at runtime rather than ignoring, which trips people up (option D) since most other hints genuinely are inert to runtime code.
Q19. What is cast() for, and what does it do at runtime?
from typing import cast
def get_config() -> dict:
...
raw = get_config()
port = cast(int, raw["port"])
-
cast(int, x)convertsxto anint, likeint(x), and raises if conversion fails -
cast()is a pure no-op at runtime — it returnsxcompletely unchanged; it exists only to tell the static checker "trust me, treat this expression asint" when the checker can't infer it itself -
cast()validates thatxis already anintand raisesTypeErrorotherwise -
cast()deep-copiesxbefore returning it
Show Answer
Answer: B — cast() is a pure no-op at runtime; it only tells the static checker to treat the expression as the given type
Explanation: Debug: typing.cast(TypeHint, value) is implemented essentially as return value — it performs no conversion, no validation, nothing. It exists purely to override a static checker's inferred type when you know something the checker can't (e.g., after runtime validation it doesn't understand). Confusing it with int(x) (option A) is a common and dangerous mistake: if raw["port"] is actually the string "8080", cast(int, raw["port"]) happily returns the string unchanged, and a later arithmetic operation on port will fail with a TypeError far from where the real bug is — the correct fix is to actually convert/validate the value, e.g. port = int(raw["port"]), not just cast it.
Q20. What does declaring a TypeVar as covariant (TypeVar("T_co", covariant=True)) enable that an invariant TypeVar does not?
from typing import TypeVar, Generic
T_co = TypeVar("T_co", covariant=True)
class ReadOnlyBox(Generic[T_co]):
def __init__(self, item: T_co) -> None:
self._item = item
def get(self) -> T_co:
return self._item
def print_box(box: "ReadOnlyBox[object]") -> None:
print(box.get())
int_box: ReadOnlyBox[int] = ReadOnlyBox(42)
print_box(int_box)
- Covariance has a runtime performance cost proportional to the number of subtypes
- With a covariant
T_co, a static checker allowsReadOnlyBox[int]to be used whereverReadOnlyBox[object]is expected (sinceintis a subtype ofobject), matching the intuitive subtyping of read-only containers; an invariantTypeVarwould reject this assignment as a type error even though it works fine at runtime - Covariance lets you mutate
_itemto any type at runtime without restriction - Covariant TypeVars are required for any class that uses
__init__
Show Answer
Answer: B — Covariance lets a static checker treat ReadOnlyBox[int] as compatible with ReadOnlyBox[object], matching real subtyping for read-only containers; invariant would reject it
Explanation: Idiom: By default, TypeVars are invariant — a checker treats ReadOnlyBox[int] and ReadOnlyBox[object] as unrelated types even though int is an object. Marking T_co covariant tells the checker it's safe to treat the relationship like the underlying type relationship because the box is read-only (get() only, never accepts a new T_co from outside after construction) — this mirrors why Sequence[int] is a Sequence[object] in typeshed but list[int] is intentionally not treated as a list[object] (since list is mutable and allows appending, which invariance protects against). None of this variance machinery has any runtime effect at all — it exists solely to make static checking of generic containers match real-world expectations.