Skip to main content

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(), and issubclass()
  • 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 typing module: List, Dict, Optional, Union, Any, Tuple, and Callable
  • mypy: what it is, how it uses annotations, and why it cannot change runtime semantics
  • Type narrowing: how isinstance checks guide static analysis
  • Generic types and type variables
  • dataclasses as 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 = pointerJavaScript: 1 + "2" = "12"
Strong (no implicit coercions)Java: type errors caught at compile timePython: 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.

tip

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.

note

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.

note

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.

danger

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]: ...
tip

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:

  1. Registry[T] is parameterized by a type T at instantiation time
  2. .set(key: str, value: T) stores the value; raises TypeError if value is not an instance of T
  3. .get(key: str) -> T retrieves the value; raises KeyError if not found
  4. .keys() returns a list of registered keys
  5. The registry should be fully annotated with type hints
  6. Include a brief comment explaining why you chose isinstance() over type() 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

ConceptWhat It MeansPython Example
Dynamic typingTypes checked at runtime, not compile timex = 5; x = "hi" - no error
Strong typingNo implicit coercions between incompatible types"5" + 5TypeError
Duck typingUse objects by behavior, not declared typeAny object with .write() works as a writer
type(obj)Exact type - ignores inheritancetype(True) is intFalse
isinstance(obj, T)Type check respecting inheritanceisinstance(True, int)True
isinstance(obj, (T1, T2))Check against multiple typesisinstance(3, (int, float))True
issubclass(A, B)Check class hierarchy (not instances)issubclass(bool, int)True
x: int = 5Variable annotation (not enforced)mypy checks it; runtime ignores it
__annotations__Where annotations are storedfunc.__annotations__{'a': int, ...}
Optional[T]T or NoneOptional[str] = Union[str, None]
Union[T1, T2]Either typeUnion[int, str] or int | str (3.10+)
List[T], Dict[K,V]Parameterized containersList[float], Dict[str, int]
AnyOpts out of type checkingUse sparingly
TypeVar("T")Type variable for genericsdef first(x: List[T]) -> T
mypyStatic type checker, non-runtimemypy myfile.py
Type narrowingisinstance narrows type in a branchif 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 T performs 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 typing module provides Optional, Union, List, Dict, Tuple, Callable, Any, and TypeVar for composing complex type annotations compatible with mypy and Python 3.7+.
  • dataclasses read __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.
© 2026 EngineersOfAI. All rights reserved.