Type System and Dynamic Typing - Engineering the Runtime Contract
Reading time: ~25 minutes | Level: Foundation → Engineering
Run this in your head. What happens?
def add(a: int, b: int) -> int:
return a + b
result = add("hello", " world")
print(result)
Most engineers who have glanced at Python's type annotation syntax expect a TypeError. The function is annotated to accept only int. The argument is a str. Surely Python enforces that?
It does not. The output is:
hello world
This surprises people who come from Java or TypeScript, where type declarations are enforced by the compiler. In Python, those int annotations are metadata - documentation attached to the function object. They do not change runtime behavior at all. Python happily concatenated the two strings, because the + operator on strings is valid, and Python checked nothing else.
This is not a bug or an oversight. It is the consequence of a deliberate, deeply considered design: Python is dynamically typed and its type annotation system is optional and non-enforced. Understanding what that actually means - and what it does not mean - is one of the most important conceptual shifts an engineer makes when working seriously with Python.
What You Will Learn
- The precise definitions of static vs dynamic typing and strong vs weak typing
- Why Python is both dynamically typed and strongly typed - and why those are different axes
- Duck typing: what it is, how Python's runtime uses it, where it is powerful, and where it bites
- Runtime type checking with
type(),isinstance(), andissubclass() - PEP 484 type annotations: syntax, where they are stored, and why they do not run at runtime
- The
__annotations__dict and what it contains - The
typingmodule:List,Dict,Optional,Union,Any,Tuple, andCallable - mypy: what it is, how it uses annotations, and why it cannot change runtime semantics
- Type narrowing: how
isinstancechecks guide static analysis - Generic types and type variables
dataclassesas a practical application of annotations- Engineering pitfalls: annotation confusion,
type()overuse, version incompatibilities
Prerequisites
- Previous lesson: "Everything Is an Object" (you must understand that objects carry type information)
- Basic Python functions and classes
- Conceptual awareness that other languages like Java or C have compile-time type checking
The Two Axes: Static/Dynamic and Strong/Weak
Engineers often conflate two independent properties of type systems. Getting these right changes how you reason about Python.
| Static (compile-time checks) | Dynamic (runtime checks) | |
|---|---|---|
| Weak (implicit coercions) | C: int + pointer = pointer | JavaScript: 1 + "2" = "12" |
| Strong (no implicit coercions) | Java: type errors caught at compile time | Python: type errors raised at runtime |
Static typing means the type checker runs before your program executes - at compile time. The compiler verifies that every operation is type-safe before a single line of your program runs. Java, C++, Rust, and Go are statically typed.
Dynamic typing means type checking happens at runtime, as operations are executed. Python, Ruby, and JavaScript are dynamically typed.
Strong typing means the language does not silently coerce values of incompatible types. If you try an invalid operation, you get an error rather than a surprising implicit conversion.
Weak typing means the language performs implicit coercions. In JavaScript, "5" - 3 gives 2 - the string was silently converted to a number. In C, you can add integers and pointers.
Python is dynamically and strongly typed. It does not check types before execution, but when an invalid operation is attempted at runtime, it raises an error rather than silently converting:
print("5" + 5)
# TypeError: can only concatenate str (not "int") to str
No silent coercion. No implicit conversion. A clean error.
Variables Do Not Have Types - Objects Do
This is the central difference between Python's dynamic type system and static systems. In Java, a variable int x permanently holds integer values. The variable itself has a type. In Python, a variable is just a name - a reference to an object. The object has a type; the name does not.
x = 42
print(type(x)) # <class 'int'>
x = "hello"
print(type(x)) # <class 'str'>
x = [1, 2, 3]
print(type(x)) # <class 'list'>
No error. No redeclaration. The name x simply rebinds to a new object. The old 42 object, now with no references pointing to it, becomes eligible for garbage collection. type() queries the object's type, not the variable's.
VARIABLE BINDING MODEL
======================
In Java:
+----------+ +---------+
| int x |------->| 42 | x can ONLY point to int values
+----------+ +---------+
In Python:
+----------+ +---------+
| x |------->| 42 | (int object)
+----------+ +---------+
After x = "hello":
+----------+ +-----------+
| x |------->| "hello" | (str object)
+----------+ +-----------+
+---------+
| 42 | (int object, refcount drops to 0)
+---------+
Duck Typing: Behavior Over Ancestry
The phrase "duck typing" comes from the saying: "If it walks like a duck and quacks like a duck, it's a duck." In Python, rather than checking what type an object is, you rely on whether it supports the operations you need.
def save_data(writer, data):
writer.write(data)
This function does not check whether writer is a FileWriter or a DatabaseWriter or a NetworkSocket. It calls .write(). Any object that has a .write() method works:
import io
# A real file
with open("output.txt", "w") as f:
save_data(f, "Hello, file!")
# An in-memory buffer
buf = io.StringIO()
save_data(buf, "Hello, buffer!")
# A custom object with no inheritance required
class LoggingWriter:
def write(self, data):
print(f"[LOG] Writing: {data}")
save_data(LoggingWriter(), "Hello, logger!")
LoggingWriter inherits from nothing except object. It does not implement an interface or extend an abstract class. It simply has a write method, and that is enough. This is duck typing in action.
Duck typing makes Python code naturally more flexible than statically typed code. A function that accepts a Writer interface in Java must be passed a class that explicitly declares it implements Writer. In Python, any object with the right methods works - including mocks in tests, in-memory objects, and objects from third-party libraries you did not write.
When Duck Typing Bites You
The flexibility of duck typing has a cost: the error appears late. If you call save_data(42, "hello"), the error does not appear until the .write() call is reached at runtime - potentially deep inside a complex workflow, potentially only in production when a specific code path is exercised:
save_data(42, "hello")
# AttributeError: 'int' object has no attribute 'write'
In a statically typed language, this would be caught at compile time. In Python, you need tests, type annotations checked by mypy, or defensive isinstance guards at API boundaries to catch this class of error early.
Runtime Type Checking: type(), isinstance(), issubclass()
Python provides three built-in tools for querying the type system at runtime.
type(obj)
Returns the exact type object of obj. Use it when you need to know the precise class, not just whether the object belongs to a family:
print(type(42)) # <class 'int'>
print(type(True)) # <class 'bool'> -- bool is a subclass of int!
print(type([])) # <class 'list'>
Note the surprising result: type(True) is bool, not int. Even though True behaves as the integer 1 in arithmetic, its type is bool. This matters when you use exact type comparison:
print(type(True) is int) # False -- exact match fails
print(type(True) is bool) # True
isinstance(obj, classinfo)
Returns True if obj is an instance of classinfo or any subclass of it. This is the tool you should reach for in nearly all production code:
print(isinstance(True, bool)) # True
print(isinstance(True, int)) # True -- bool IS a subclass of int
print(isinstance(3.14, (int, float))) # True -- accepts a tuple of types
print(isinstance("hi", (int, float))) # False
The tuple form is especially useful for accepting multiple compatible types without chained or conditions.
issubclass(cls, classinfo)
Checks whether a class (not an instance) is a subclass of another class:
print(issubclass(bool, int)) # True
print(issubclass(int, object)) # True
print(issubclass(list, dict)) # False
This is useful when working with metaclass machinery, class factories, or plugin systems that register and validate class objects.
Type Annotations (PEP 484): Syntax and Runtime Semantics
Python 3.5 introduced type annotations (PEP 484) as a way to attach type information to variables and functions. The syntax looks like enforcement but is not:
# Variable annotation
x: int = 42
name: str = "Alice"
scores: list[float] = [9.5, 8.0, 7.5]
# Function annotation
def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}! " * times).strip()
These annotations are valid Python syntax. They are parsed by the interpreter. But they are not enforced at runtime. The interpreter does not add any type-checking code. The annotations are stored and accessible, but nothing acts on them automatically.
# This runs without any error
x: int = "I am clearly not an int"
print(x) # I am clearly not an int
print(type(x)) # <class 'str'>
Python accepted the string value despite the int annotation. The annotation is documentation, not a constraint.
Type annotations exist to serve two audiences: human readers (who benefit from knowing what type a function expects) and static analysis tools (mypy, pyright, your IDE's type checker) that read the annotations and flag violations before you run the code. The Python runtime itself ignores them for enforcement purposes.
__annotations__: Where Annotations Are Stored
Python stores annotations in a dictionary called __annotations__, accessible on functions, classes, and modules:
def add(a: int, b: int) -> int:
return a + b
print(add.__annotations__)
# {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}
For classes:
class Point:
x: float
y: float
label: str = "origin"
print(Point.__annotations__)
# {'x': <class 'float'>, 'y': <class 'float'>, 'label': <class 'str'>}
Frameworks use __annotations__ heavily. FastAPI reads your function's annotations to generate validation logic, OpenAPI schemas, and documentation. Pydantic uses class annotations to define field types and run validation. SQLAlchemy uses them for column type mapping. The annotations you write are the interface through which tooling inspects your intent.
The typing Module: Composing Complex Types
For anything beyond simple built-in types, use the typing module (Python 3.5+). In Python 3.9+ many of these are available directly from built-ins, but typing remains the standard for compatibility.
from typing import List, Dict, Optional, Union, Any, Tuple, Callable
Optional[T]
Means the value is either T or None. This is extremely common:
from typing import Optional
def find_user(user_id: int) -> Optional[str]:
users = {1: "Alice", 2: "Bob"}
return users.get(user_id) # returns None if not found
result = find_user(99)
# result is Optional[str] -- could be str or None
Optional[str] is equivalent to Union[str, None].
Union[T1, T2, ...]
Means the value can be any one of the listed types:
from typing import Union
def process(value: Union[int, str]) -> str:
if isinstance(value, int):
return f"Integer: {value}"
return f"String: {value}"
In Python 3.10+, you can use int | str instead of Union[int, str].
List, Dict, Tuple
Parameterized container types:
from typing import List, Dict, Tuple
def compute_stats(scores: List[float]) -> Dict[str, float]:
return {
"mean": sum(scores) / len(scores),
"max": max(scores),
"min": min(scores),
}
def get_point() -> Tuple[float, float]:
return (3.0, 4.0)
In Python 3.9+, you can write list[float], dict[str, float], tuple[float, float] directly without importing from typing.
Callable
For annotating functions as arguments:
from typing import Callable
def apply_twice(func: Callable[[int], int], value: int) -> int:
return func(func(value))
print(apply_twice(lambda x: x + 1, 5)) # 7
Any
Opts out of type checking for a specific value. Effectively tells mypy "do not check this":
from typing import Any
def flexible(value: Any) -> Any:
return value
Use Any sparingly - it defeats the purpose of annotations in the areas it covers.
mypy: Static Analysis Without Runtime Cost
mypy is a separate tool (not part of the Python runtime) that reads your source code, processes your type annotations, and reports type errors - without running your code.
pip install mypy
mypy your_file.py
Consider this file:
# example.py
def add(a: int, b: int) -> int:
return a + b
result = add("hello", 5)
Running mypy:
mypy example.py
example.py:4: error: Argument 1 to "add" has incompatible type "str"; expected "int"
Found 1 error in 1 file (checked 1 source file)
mypy caught the error at analysis time, without running the program. This is the compile-time safety of static typing layered on top of Python's dynamic runtime.
mypy analyzes types based on annotations. Unannotated code is treated as having Any types by default - mypy makes no assumptions about unannotated functions. You can configure mypy to be stricter (--strict flag) or more lenient depending on your project's needs.
The critical point: mypy findings are advisory. You can fix them, or you can run the program anyway. The Python interpreter does not consult mypy. mypy is a linter, not a compiler.
PYTHON TOOLCHAIN
================
Your .py files
|
|---------> mypy (static analysis, reads annotations, reports errors)
| NO runtime effect
|
v
Python interpreter (executes code, ignores annotations for enforcement)
|
v
Runtime behavior
Type Narrowing: Bridging Runtime Checks and Static Analysis
Type narrowing is what happens when an isinstance() check teaches the static analyzer that a variable's type is more specific within a code branch:
from typing import Union
def process(value: Union[int, str]) -> str:
if isinstance(value, int):
# Here, mypy KNOWS value is int
return f"Doubled: {value * 2}"
else:
# Here, mypy KNOWS value is str
return f"Upper: {value.upper()}"
Inside the if isinstance(value, int) branch, mypy narrows value's type from Union[int, str] to int. This allows it to verify that value * 2 is valid. In the else branch, it knows value must be str (the only remaining type in the union) and verifies that .upper() is valid.
Type narrowing is why isinstance() is not just a runtime tool - it is also the mechanism by which runtime guards and static analysis communicate.
Generic Types and Type Variables
Sometimes you need to express that a function works with any type, but the output type should match the input type. That is what type variables do:
from typing import TypeVar, List
T = TypeVar("T")
def first(items: List[T]) -> T:
return items[0]
x: int = first([1, 2, 3]) # mypy knows this returns int
y: str = first(["a", "b", "c"]) # mypy knows this returns str
TypeVar("T") creates a type variable named T. When mypy sees first([1, 2, 3]), it binds T = int for that call and verifies the return value accordingly. This is how Python expresses generic programming in the type system without changing runtime behavior.
dataclasses: Annotations with Teeth (at Runtime)
dataclasses is one of the few parts of the standard library where annotations actually drive runtime behavior - but through explicit opt-in, not automatic enforcement.
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
label: str = "origin"
p = Point(3.0, 4.0)
print(p) # Point(x=3.0, y=4.0, label='origin')
print(p.x) # 3.0
The @dataclass decorator reads Point.__annotations__ and automatically generates __init__, __repr__, __eq__, and optionally __hash__, __lt__, etc. It does not enforce that x is actually a float at runtime, but it uses the annotation to understand the field structure.
This pattern - a decorator or metaclass reading __annotations__ and generating behavior from it - is how FastAPI, Pydantic, SQLAlchemy Mapped Columns, and many other frameworks work. Understanding that annotations are metadata stored in __annotations__ explains why this decorator-based approach is possible.
The Annotation vs Enforcement Gap
This is the most important pitfall for engineers new to Python's type system. The annotation says one thing; the runtime does not care.
from typing import List
def process_scores(scores: List[int]) -> float:
return sum(scores) / len(scores)
# Annotation says List[int]. Let's pass strings.
result = process_scores(["10", "20", "30"])
# No error from Python. But sum("10", "20", "30") will fail...
# Actually: sum(["10", "20"]) raises TypeError.
# The error comes from sum(), not from the annotation.
The TypeError you eventually get is a runtime error from sum() trying to add strings. It is not an annotation violation error. Python never checked the annotation.
Do not annotate a function and assume callers will pass the correct types. At public API boundaries - functions called by user code, HTTP handler functions, CLI entry points - validate inputs explicitly using isinstance() checks, Pydantic models, or similar tools. Annotations document intent; they do not enforce it.
Pitfalls
Pitfall 1: Annotating but Not Validating at Boundaries
# WRONG at a public API boundary
def create_user(user_id: int, name: str) -> dict:
return {"id": user_id, "name": name.upper()}
# Caller passes wrong types
create_user("not_an_int", 123)
# AttributeError: 'int' object has no attribute 'upper'
# The error is confusing -- it comes from name.upper(), not the annotation
Fix: validate at boundaries, or use Pydantic:
from pydantic import BaseModel
class UserRequest(BaseModel):
user_id: int
name: str
# Pydantic validates and coerces at instantiation time
req = UserRequest(user_id="5", name="Alice") # "5" is coerced to int 5
Pitfall 2: Using type() Where isinstance() Is Correct
def is_number(x):
return type(x) is int or type(x) is float # FRAGILE
# Fails silently for subclasses
class MyInt(int):
pass
print(is_number(MyInt(5))) # False -- wrong!
# Correct:
def is_number(x):
return isinstance(x, (int, float))
print(is_number(MyInt(5))) # True
Pitfall 3: Annotation Syntax That Breaks Older Python
Python 3.9 introduced built-in generic syntax (list[int] instead of List[int]). Python 3.10 introduced the X | Y union syntax. If your code must support Python 3.7 or 3.8, use from typing import List, Union, Optional and avoid the newer syntax:
# Works in Python 3.9+ only:
def process(scores: list[float]) -> dict[str, float]:
...
# Works in Python 3.7+:
from typing import List, Dict
def process(scores: List[float]) -> Dict[str, float]:
...
# Python 3.10+ union syntax:
def find(value: int | str) -> str | None: ...
# Python 3.7+ equivalent:
from typing import Union, Optional
def find(value: Union[int, str]) -> Optional[str]: ...
If you add from __future__ import annotations at the top of your file (Python 3.7+), all annotations are treated as strings (lazily evaluated) rather than executed at import time. This allows you to use Python 3.10+ annotation syntax in older Python versions, at the cost of annotations no longer being live type objects without explicit eval().
Interview Questions
Q1: What is the difference between static typing and dynamic typing? Where does Python fall?
Static typing means the type of every expression is verified by a compiler or type checker before the program runs. Errors in type usage are caught at compile time - you cannot even compile code with a type mismatch. Languages like Java, C++, Rust, and Go use static typing.
Dynamic typing means type checking happens at runtime, as operations are executed. Python, Ruby, and JavaScript are dynamically typed. When you call "hello" + 5 in Python, no error occurs until that line is actually executed. Type information is carried by the object at runtime, not inferred by a compiler. The advantage is flexibility - you can write more general code, rapid-prototype without type ceremony, and pass any object that supports the required behavior. The cost is that type errors surface later, potentially in production, and require tests or static analysis tools to catch.
Q2: What does "strongly typed" mean, and why is Python strongly typed despite being dynamic?
Strong vs weak typing is orthogonal to static vs dynamic typing. A strongly typed language does not perform implicit type coercions between incompatible types. A weakly typed language performs implicit conversions.
Python is strongly typed: "5" + 5 raises TypeError rather than silently converting the string to an integer (as JavaScript would) or the integer to a string. Python requires you to be explicit: int("5") + 5 or "5" + str(5). This prevents a whole class of subtle bugs where values silently change meaning. Being dynamically typed and strongly typed means Python checks types at runtime but does so strictly, without implicit coercions.
Q3: What is duck typing, and when does it become dangerous?
Duck typing is the practice of using objects based on their supported operations rather than their declared type. A function that calls obj.write(data) works with any object that has a write method - file objects, io.StringIO buffers, custom loggers, database cursors - without requiring inheritance from a common base class.
Duck typing is powerful because it makes code reusable against anything with the right interface, which includes mocks in tests and objects from third-party libraries. It becomes dangerous when you rely on an implicit contract that is never documented or enforced, and a caller passes an object that partially satisfies the contract. The error appears deep in the call stack at runtime rather than at the entry point. At public API boundaries - especially in large teams - using isinstance() guards, abstract base classes from the abc module, or Pydantic validation provides a safety net while preserving the flexibility.
Q4: What are type annotations in Python, and do they affect runtime behavior?
Type annotations (PEP 484, introduced in Python 3.5) are syntax for attaching type information to variables, function parameters, and return values. They look like x: int = 5 or def greet(name: str) -> str. They are valid Python syntax and are stored in __annotations__ dictionaries on functions, classes, and modules.
They do not affect runtime behavior. The Python interpreter does not add any type-checking code based on annotations. A function annotated to receive int will happily accept a str at runtime without any error from the annotation system. Annotations serve two purposes: documentation (human readers understand the expected types) and static analysis (tools like mypy and pyright read annotations and report type violations without running the code). Frameworks like FastAPI and Pydantic also read annotations at import time to generate validation logic, but that is explicit framework behavior using __annotations__, not automatic Python behavior.
Q5: What is mypy and how does it fit into Python's type ecosystem?
mypy is a standalone static type checker for Python - a command-line tool you install separately (pip install mypy) and run against your source files before executing them. It reads your code and its type annotations, performs type inference, and reports incompatibilities: passing a str to a function expecting int, calling a method that does not exist on a type, returning the wrong type from a function.
Crucially, mypy operates entirely outside the Python runtime. Running mypy does not change what python your_file.py does. Its findings are advisory - you can fix them, or you can ignore them and run the code anyway. The Python interpreter does not check mypy's output. mypy gives you the benefits of compile-time type safety as an opt-in tool, without changing Python's dynamic runtime semantics. In professional Python projects, mypy (or pyright, the Microsoft alternative) is typically run in CI pipelines alongside tests to catch type errors before they reach production.
Q6: What is isinstance() doing during type narrowing, and why does it matter for mypy?
At runtime, isinstance(obj, SomeClass) simply walks the MRO of type(obj) and returns True if SomeClass appears anywhere in the chain. This is pure runtime logic with no interaction with mypy.
However, mypy recognizes isinstance() as a type narrowing construct. When mypy analyzes a code branch that begins with if isinstance(value, int):, it infers that within that branch, value has the narrowed type int - even if value was declared as Union[int, str] or Any. This narrowed type is used to verify that operations in the branch are valid: mypy will allow value * 2 inside an int branch but flag value.upper() as an error. In the corresponding else branch, mypy narrows value to the remaining types. This mechanism is how runtime isinstance() guards and static analysis communicate - your defensive runtime check simultaneously documents the type constraint in a way that mypy understands.
Graded Practice Challenges
Level 1 - Predict the Output
What does this code print? There are no tricks from external libraries - only standard Python semantics.
x: int = "fifty"
y: str = 100
print(type(x))
print(type(y))
print(x + " dollars")
print(y + 50)
Show Answer
<class 'str'>
<class 'int'>
fifty dollars
150
Type annotations (x: int = "fifty") do not enforce anything at runtime. Python stores "fifty" (a str) in x and 100 (an int) in y without any complaint. type(x) queries the actual object's type, which is str. type(y) is int. x + " dollars" is str + str = "fifty dollars" (valid). y + 50 is int + int = 150 (valid). mypy would flag this file with two annotation violations, but the Python runtime runs it cleanly.
Level 2 - Debug This
This function is supposed to accept a list of values that are all either int or float, compute their average, and return a float. It fails on certain inputs in a confusing way. Identify every problem - the type safety problem and the logic problem - and produce a fixed version with proper guards.
def average(values):
return sum(values) / len(values)
print(average([1, 2, 3])) # works
print(average([1, 2, "three"])) # fails
print(average([])) # fails
print(average("not a list")) # surprisingly does not fail?
Show Answer
Problem 1 - type safety: The function has no guard against non-numeric elements. sum([1, 2, "three"]) raises TypeError: unsupported operand type(s) for +: 'int' and 'str'. The error message is about sum, not about average's contract.
Problem 2 - empty input: average([]) raises ZeroDivisionError: division by zero because len([]) == 0. This should be handled explicitly with a clear error.
Problem 3 - the string surprise: average("not a list") does not immediately fail because a string is iterable and len("not a list") works. sum("not a list") will raise TypeError on the first character - but the error message is confusing and far from the actual input problem.
Fixed version:
from typing import List, Union
def average(values: List[Union[int, float]]) -> float:
if not isinstance(values, list):
raise TypeError(
f"average() requires a list, got {type(values).__name__}"
)
if len(values) == 0:
raise ValueError("average() requires at least one value, got empty list")
for i, v in enumerate(values):
if not isinstance(v, (int, float)):
raise TypeError(
f"average() requires numeric values; "
f"element at index {i} is {type(v).__name__!r}: {v!r}"
)
return sum(values) / len(values)
print(average([1, 2, 3])) # 2.0
# average([1, 2, "three"]) # TypeError with clear message
# average([]) # ValueError with clear message
# average("not a list") # TypeError with clear message
The type annotation documents intent; the isinstance guards enforce it at runtime where it matters - at the function's entry point, producing clear error messages that point to the real problem.
Level 3 - Design Challenge
Design a simple typed registry that stores and retrieves values by key, with runtime type enforcement. Requirements:
Registry[T]is parameterized by a typeTat instantiation time.set(key: str, value: T)stores the value; raisesTypeErrorifvalueis not an instance ofT.get(key: str) -> Tretrieves the value; raisesKeyErrorif not found.keys()returns a list of registered keys- The registry should be fully annotated with type hints
- Include a brief comment explaining why you chose
isinstance()overtype()for the check
Expected usage:
int_registry = Registry(int)
int_registry.set("answer", 42)
int_registry.set("count", 10)
print(int_registry.get("answer")) # 42
int_registry.set("name", "Alice") # TypeError
Show Answer
from typing import TypeVar, Generic, Dict, List, Type
T = TypeVar("T")
class Registry(Generic[T]):
"""
A type-enforced key-value registry.
The type T is passed at instantiation, not at class definition time,
because Python's runtime does not retain Generic[T] type parameters.
We store the actual type object (e.g., int, str) and use isinstance()
for checking rather than type() == ..., because isinstance respects
inheritance: a Registry(int) should accept bool values (bool is a
subclass of int), which is the correct and expected behavior.
"""
def __init__(self, value_type: Type[T]) -> None:
# value_type is the actual type object (e.g., int, str, MyClass)
self._value_type: Type[T] = value_type
self._store: Dict[str, T] = {}
def set(self, key: str, value: T) -> None:
# isinstance() is correct here: accepts subclasses of value_type.
# type(value) is self._value_type would wrongly reject bool when
# value_type is int, and would wrongly reject subclass instances
# in general. isinstance matches the semantics we want.
if not isinstance(value, self._value_type):
raise TypeError(
f"Registry[{self._value_type.__name__}] cannot store "
f"{type(value).__name__!r} value {value!r}; "
f"expected an instance of {self._value_type.__name__}"
)
self._store[key] = value
def get(self, key: str) -> T:
if key not in self._store:
raise KeyError(
f"Key {key!r} not found in registry. "
f"Available keys: {list(self._store.keys())}"
)
return self._store[key]
def keys(self) -> List[str]:
return list(self._store.keys())
def __repr__(self) -> str:
return (
f"Registry[{self._value_type.__name__}]"
f"({len(self._store)} items)"
)
# --- Usage ---
int_registry: Registry[int] = Registry(int)
int_registry.set("answer", 42)
int_registry.set("count", 10)
int_registry.set("flag", True) # OK: bool is a subclass of int
print(int_registry.get("answer")) # 42
print(int_registry.keys()) # ['answer', 'count', 'flag']
print(int_registry) # Registry[int](3 items)
try:
int_registry.set("name", "Alice")
except TypeError as e:
print(e)
# Registry[int] cannot store 'str' value 'Alice'; expected an instance of int
str_registry: Registry[str] = Registry(str)
str_registry.set("greeting", "Hello")
str_registry.set("farewell", "Goodbye")
print(str_registry.get("greeting")) # Hello
Key design decisions: We pass the actual type object int (not the string "int") so we can use isinstance(value, self._value_type) at runtime. The Generic[T] annotation is for mypy's benefit - it allows mypy to infer that int_registry.get("answer") returns int. At runtime, Generic[T] does not carry the bound type; that is why we store _value_type explicitly. We use isinstance over type() equality so that subclasses (like bool for int) are correctly accepted.
Quick Reference Cheatsheet
| Concept | What It Means | Python Example |
|---|---|---|
| Dynamic typing | Types checked at runtime, not compile time | x = 5; x = "hi" - no error |
| Strong typing | No implicit coercions between incompatible types | "5" + 5 → TypeError |
| Duck typing | Use objects by behavior, not declared type | Any object with .write() works as a writer |
type(obj) | Exact type - ignores inheritance | type(True) is int → False |
isinstance(obj, T) | Type check respecting inheritance | isinstance(True, int) → True |
isinstance(obj, (T1, T2)) | Check against multiple types | isinstance(3, (int, float)) → True |
issubclass(A, B) | Check class hierarchy (not instances) | issubclass(bool, int) → True |
x: int = 5 | Variable annotation (not enforced) | mypy checks it; runtime ignores it |
__annotations__ | Where annotations are stored | func.__annotations__ → {'a': int, ...} |
Optional[T] | T or None | Optional[str] = Union[str, None] |
Union[T1, T2] | Either type | Union[int, str] or int | str (3.10+) |
List[T], Dict[K,V] | Parameterized containers | List[float], Dict[str, int] |
Any | Opts out of type checking | Use sparingly |
TypeVar("T") | Type variable for generics | def first(x: List[T]) -> T |
| mypy | Static type checker, non-runtime | mypy myfile.py |
| Type narrowing | isinstance narrows type in a branch | if isinstance(x, int): → x is int inside |
Key Takeaways
- Python is dynamically typed (type checking at runtime) and strongly typed (no implicit coercions). These are independent properties; do not conflate them.
- In Python, variables do not have types - objects do. A name is a reference that can rebind to any object.
type()queries the object's type. - Duck typing makes Python code flexible by relying on behavior (supported methods) rather than declared type ancestry. Its cost is that type mismatches appear at runtime rather than compile time.
isinstance(obj, T)checks membership in a type's inheritance chain and is correct for nearly all type-checking code.type(obj) is Tperforms an exact match and is wrong when subclasses should be accepted.- Type annotations (PEP 484) store metadata in
__annotations__dictionaries. They do not affect runtime behavior. Python will run annotated-wrong code without any annotation-related error. - mypy is a separate static analysis tool that reads annotations and reports type violations before you run your code. It is not part of the Python runtime and cannot change what the interpreter executes.
- Type narrowing: an
isinstance()check in a conditional branch tells mypy the narrowed type within that branch, linking runtime safety checks to static analysis. - The
typingmodule providesOptional,Union,List,Dict,Tuple,Callable,Any, andTypeVarfor composing complex type annotations compatible with mypy and Python 3.7+. dataclassesread__annotations__at class creation time to generate__init__,__repr__, and__eq__. This is an explicit framework pattern, not automatic Python behavior.- At public API boundaries, validate inputs with
isinstance()or a validation library (Pydantic). Annotations document intent; they do not enforce it. Relying solely on annotations for safety is a common and costly mistake.
