Chapter 3 — Dynamic Typing, Runtime Types, and Type Annotations
1. Opening problem
Consider:
def double(value: int) -> int:
return value * 2
print(double(10))
print(double("ab"))
Many TypeScript developers expect the second call to be rejected by Python.
It is not.
The output is:
20
abab
The annotation int describes an intended contract for developers and static tools. It usually does not enforce the contract at runtime.
This chapter explains the relationship among:
- values;
- runtime types;
- names;
- annotations;
- static type checkers;
- runtime validation;
- duck typing;
- structural subtyping;
- API boundaries.
2. Dynamic typing
Python is dynamically typed.
This means the type belongs to the object at runtime, not permanently to the variable name.
value = 10
value = "ten"
value = [10]
The name value is rebound to objects of different types.
In TypeScript:
let value: number = 10;
value = "ten";
the compiler rejects the reassignment.
However, TypeScript's types are erased when JavaScript runs. Python's type annotations are also not generally runtime enforcement. Both ecosystems distinguish development-time analysis from runtime execution, but they start from different foundations.
3. Strong typing
Python is also often described as strongly typed.
Dynamic does not mean that arbitrary operations are silently converted.
result = "10" + 5
raises TypeError.
JavaScript may coerce values:
"10" + 5
produces "105".
Python usually requires explicit conversion:
result = int("10") + 5
or:
result = "10" + str(5)
Strong typing does not mean every API is strict. It means the language does not generally erase type differences through broad implicit coercion.
4. Runtime type inspection
type()
value = 10
print(type(value))
Use type() when the exact concrete type matters.
isinstance()
if isinstance(value, int):
...
isinstance() supports inheritance and type tuples:
if isinstance(value, (int, float)):
...
Use it sparingly. Excessive runtime type branching can indicate that behaviour should be moved behind a protocol or method.
Problematic design
def serialize(value):
if isinstance(value, User):
...
elif isinstance(value, Order):
...
elif isinstance(value, Invoice):
...
Possible alternatives:
- a serialization protocol;
- registered handlers;
- singledispatch;
- methods on domain objects;
- schema-specific boundary models.
5. Type annotations
def calculate_total(
prices: list[float],
tax_rate: float,
) -> float:
subtotal = sum(prices)
return subtotal * (1 + tax_rate)
Annotations improve:
- editor support;
- refactoring safety;
- documentation;
- static analysis;
- API discovery;
- code review;
- architecture communication.
They do not automatically provide:
- input parsing;
- runtime validation;
- serialization;
- database constraints;
- authorization;
- business validation.
6. Static analysis
A static type checker can report:
def greet(name: str) -> str:
return f"Hello, {name}"
greet(42)
Typical command:
mypy app.py
A type checker analyses the program without relying on every line being executed.
Static analysis can catch:
- incompatible arguments;
- incorrect return values;
- unreachable paths;
- missing attributes;
- unsafe optional access;
- generic-type mistakes;
- protocol incompatibility.
It cannot prove every runtime property.
For example, this is typed as an integer:
def load_age() -> int:
return int(input("Age: "))
The user may still enter invalid text, producing ValueError.
Static typing and runtime validation solve different problems.
7. Type inference
Python type checkers often infer local types:
count = 10
name = "Steve"
users = ["Mika", "Lily"]
Explicit annotations are most valuable at boundaries:
- public functions;
- methods;
- class attributes;
- module-level constants;
- complex local values;
- empty collections;
- callbacks;
- framework integrations.
For example:
users: list[str] = []
An empty list does not reveal its intended element type.
Avoid annotating every obvious local variable unless it improves understanding.
8. Union types
def find_user(user_id: int) -> User | None:
...
The return value may be either a User or None.
A caller must narrow:
user = find_user(42)
if user is None:
raise LookupError("User not found")
print(user.email)
TypeScript equivalent:
function findUser(userId: number): User | undefined {
// ...
}
Python commonly uses None for absence, but a result type, exception, or sentinel may better express some domains.
9. Any versus object
Any
from typing import Any
def process(value: Any) -> None:
value.nonexistent_method()
Static tools permit almost any operation on Any.
Any creates an escape hatch from type safety.
object
def process(value: object) -> None:
value.nonexistent_method()
A type checker reports an error because every Python value is an object, but object promises almost no specific behaviour.
This resembles the difference between TypeScript's any and unknown:
- Python
Anyis similar to TypeScriptany; - Python
objectis not identical tounknown, but it encourages narrowing before specific operations.
Prefer object when the value may be anything but unsafe operations should not be silently accepted.
10. Type narrowing
def normalize(value: str | bytes) -> str:
if isinstance(value, bytes):
return value.decode("utf-8")
return value.strip()
After the isinstance check, a type checker understands that value is bytes in one branch and str in the other.
Other narrowing techniques include:
is None;isinstance;issubclass;- pattern matching;
- custom type guards;
- assertions;
- discriminated unions using literals.
11. Literal types
from typing import Literal
LogLevel = Literal["debug", "info", "warning", "error"]
def configure_logging(level: LogLevel) -> None:
...
A type checker can reject:
configure_logging("verbose")
But Python still accepts the string at runtime unless validation is added.
For runtime enforcement:
VALID_LEVELS = {"debug", "info", "warning", "error"}
def configure_logging(level: str) -> None:
if level not in VALID_LEVELS:
raise ValueError(f"Unsupported logging level: {level}")
You may combine both:
def configure_logging(level: LogLevel) -> None:
if level not in VALID_LEVELS:
raise ValueError(f"Unsupported logging level: {level}")
The annotation helps internal callers. Validation protects runtime boundaries.
12. Typed dictionaries
External JSON data often has dictionary shape:
from typing import TypedDict
class UserPayload(TypedDict):
id: int
email: str
active: bool
def send_welcome_email(user: UserPayload) -> None:
...
This gives static structure to dictionaries.
However, it does not validate incoming JSON:
payload = json.loads(request_body)
The resulting object is runtime data from an untrusted boundary.
You still need validation.
Approaches include:
- explicit parsing functions;
- dataclass constructors;
- schema-validation libraries;
- framework request models;
- manual checks.
13. Dataclasses as internal models
from dataclasses import dataclass
@dataclass(frozen=True)
class User:
user_id: int
email: str
active: bool
A parsing function can protect the boundary:
from collections.abc import Mapping
from typing import Any
def parse_user(data: Mapping[str, Any]) -> User:
user_id = data.get("id")
email = data.get("email")
active = data.get("active")
if not isinstance(user_id, int):
raise ValueError("id must be an integer")
if not isinstance(email, str):
raise ValueError("email must be a string")
if not isinstance(active, bool):
raise ValueError("active must be a boolean")
return User(
user_id=user_id,
email=email,
active=active,
)
Now the internal application can rely on the User object.
14. Structural subtyping with protocols
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None:
...
def safely_close(resource: SupportsClose) -> None:
resource.close()
A class does not need to inherit from SupportsClose.
It only needs a compatible method.
class Connection:
def close(self) -> None:
print("Connection closed")
safely_close(Connection())
This is similar to TypeScript structural interfaces.
Protocols are useful when:
- several unrelated types share behaviour;
- you want dependency inversion;
- inheritance would be artificial;
- tests use fakes;
- framework objects should remain decoupled.
15. Callable types
from collections.abc import Callable
Transformer = Callable[[str], str]
def apply_transform(
value: str,
transform: Transformer,
) -> str:
return transform(value)
Usage:
def normalize_email(value: str) -> str:
return value.strip().lower()
result = apply_transform(
" Steve@Example.com ",
normalize_email,
)
For complex callbacks, a protocol can be more descriptive:
from typing import Protocol
class EmailNormalizer(Protocol):
def __call__(self, value: str) -> str:
...
Protocols can describe keyword parameters and overloads more precisely than a simple Callable.
16. Generics
from typing import TypeVar
T = TypeVar("T")
def first(values: list[T]) -> T:
if not values:
raise ValueError("Expected at least one value")
return values[0]
The type variable preserves the relationship between input and output.
name = first(["Mika", "Lily"]) # inferred as str
score = first([10, 20]) # inferred as int
A poor alternative:
def first(values: list[object]) -> object:
...
This loses the specific output type.
TypeScript equivalent:
function first<T>(values: T[]): T {
if (values.length === 0) {
throw new Error("Expected at least one value");
}
return values[0];
}
17. Runtime validation versus type checking
Suppose an HTTP endpoint receives:
{
"quantity": "10"
}
A Python annotation:
def create_order(quantity: int) -> None:
...
does not transform "10" into 10.
At the boundary, decide intentionally:
- reject strings;
- parse numeric strings;
- accept only JSON numbers;
- apply domain constraints;
- return detailed validation errors.
Example:
def parse_quantity(value: object) -> int:
if isinstance(value, bool):
raise ValueError("quantity must not be boolean")
if isinstance(value, int):
quantity = value
elif isinstance(value, str) and value.isdigit():
quantity = int(value)
else:
raise ValueError("quantity must be an integer")
if quantity <= 0:
raise ValueError("quantity must be positive")
return quantity
Notice the boolean check. In Python, bool is a subclass of int, so:
isinstance(True, int)
is True.
Runtime details matter.
18. Boolean and integer relationship
print(True + True)
print(False + 10)
Output:
2
10
This historical relationship can be surprising.
Therefore:
def require_integer(value: object) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError("Expected an integer")
return value
Use exact domain validation rather than assuming broad runtime categories match business meaning.
19. Type aliases and domain meaning
UserId = int
OrderId = int
These aliases improve readability, but most type checkers still treat them as ordinary integers.
For stronger separation, use value objects:
from dataclasses import dataclass
@dataclass(frozen=True)
class UserId:
value: int
@dataclass(frozen=True)
class OrderId:
value: int
Now accidentally passing an OrderId where a UserId is expected can be detected.
There is always a trade-off:
- primitive aliases are lighter;
- value objects are safer and more expressive;
- too many wrappers can create friction.
Use stronger modelling where mixing values would cause serious defects.
20. The danger of cast
from typing import cast
value = cast(User, payload)
cast tells the type checker to trust you. It does not convert or validate the value.
This is unsafe:
payload = {"email": "test@example.com"}
user = cast(User, payload)
At runtime, user is still a dictionary.
Use cast only when:
- runtime guarantees exist but the checker cannot infer them;
- the guarantee is documented;
- tests cover the assumption;
- a better type model is impractical.
Do not use cast to silence valid warnings.
21. Production design: typed core, validated boundary
A robust architecture often follows this principle:
Parse and validate untrusted data at the boundary, then pass strongly modelled values through the internal application.
Flow:
HTTP / message / file
↓
runtime validation and parsing
↓
typed domain objects
↓
application logic
↓
serialization at the outgoing boundary
This design reduces repeated checks throughout the codebase.
Example:
@dataclass(frozen=True)
class CreateUserCommand:
email: EmailAddress
display_name: str
The command should already contain valid domain values. The application service should not repeatedly revalidate raw JSON.
22. English vocabulary
| Term | Meaning |
|---|---|
| dynamic typing | types are associated with runtime objects |
| static analysis | checking code without executing every path |
| type annotation | metadata describing an expected type |
| inference | deriving a type from context |
| narrowing | reducing a union to a more specific type |
| structural typing | compatibility based on shape or behaviour |
| nominal typing | compatibility based on declared identity |
| runtime validation | checking real input while the program runs |
| type erasure | removal of static type information before runtime |
| escape hatch | a feature that bypasses normal safety checks |
Professional sentences
- "The annotation documents the contract but does not enforce it at runtime."
- "Untrusted input must be validated at the application boundary."
- "Using
Anyhere removes most of the value of static analysis." - "A protocol expresses the required behaviour without coupling the implementation."
- "The parser converts weak external data into a strong internal model."
- "The type checker cannot infer this invariant, so the assumption must be documented."
23. Speaking task
Explain for seven minutes:
Why type hints do not make Python a statically typed runtime language.
Include:
- runtime objects;
- annotations;
- type checkers;
- TypeScript erasure;
Any;- boundary validation;
- protocols.
24. Writing task
Write a 350-word architecture note titled:
Typed Core, Validated Boundary
Explain how an HTTP request should move from raw JSON into a domain object and why validation should not be spread across every service method.
25. Exercises
Exercise 1: Predict the runtime
def repeat(value: str, count: int) -> str:
return value * count
print(repeat("ab", 3))
print(repeat([1, 2], 2))
Will Python reject the second call because of the annotation?
Exercise 2: Any or object?
Choose the safer type and explain:
def log_unknown_value(value: ???) -> None:
print(repr(value))
Exercise 3: Parse an age
Implement:
def parse_age(value: object) -> int:
...
Rules:
- integers are accepted;
- strings containing an integer are accepted;
- booleans are rejected;
- age must be between 0 and 130;
- all invalid values raise
ValueError.
Exercise 4: Protocol
Create a protocol for objects that can send a text message:
send(recipient: str, message: str) -> None
Implement two classes:
EmailSender;ConsoleSender.
Write one function that works with both.
Exercise 5: Generic stack
Implement a generic Stack[T] with:
push;pop;peek;is_empty.
Exercise 6: Identify missing validation
What is wrong with this boundary?
def handle_request(payload: dict[str, object]) -> None:
command = CreateOrderCommand(
quantity=cast(int, payload["quantity"]),
product_id=cast(str, payload["product_id"]),
)
order_service.create(command)
26. Complete solutions
Solution 1
Output:
ababab
[1, 2, 1, 2]
Python does not reject the second call. The annotations are not automatically enforced. Runtime behaviour follows the actual object operations.
A static checker should report the call as incompatible.
Solution 2
Use object:
def log_unknown_value(value: object) -> None:
print(repr(value))
The function needs no type-specific operation beyond repr, which every object supports.
Any would unnecessarily disable checks inside the function.
Solution 3
def parse_age(value: object) -> int:
if isinstance(value, bool):
raise ValueError("age must not be boolean")
if isinstance(value, int):
age = value
elif isinstance(value, str):
normalized = value.strip()
try:
age = int(normalized)
except ValueError as error:
raise ValueError("age must be an integer") from error
else:
raise ValueError("age must be an integer")
if not 0 <= age <= 130:
raise ValueError("age must be between 0 and 130")
return age
Solution 4
from typing import Protocol
class MessageSender(Protocol):
def send(self, recipient: str, message: str) -> None:
...
class EmailSender:
def send(self, recipient: str, message: str) -> None:
print(f"Email to {recipient}: {message}")
class ConsoleSender:
def send(self, recipient: str, message: str) -> None:
print(f"[{recipient}] {message}")
def send_welcome(
sender: MessageSender,
recipient: str,
) -> None:
sender.send(recipient, "Welcome!")
send_welcome(EmailSender(), "steve@example.com")
send_welcome(ConsoleSender(), "Steve")
Neither class inherits from the protocol. Compatibility is structural.
Solution 5
from typing import Generic, TypeVar
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._values: list[T] = []
def push(self, value: T) -> None:
self._values.append(value)
def pop(self) -> T:
if not self._values:
raise IndexError("Cannot pop from an empty stack")
return self._values.pop()
def peek(self) -> T:
if not self._values:
raise IndexError("Cannot peek into an empty stack")
return self._values[-1]
def is_empty(self) -> bool:
return not self._values
Solution 6
cast performs no runtime validation.
Potential failures:
- missing keys;
- incorrect types;
- booleans passed as integers;
- blank product IDs;
- negative quantities;
- unsupported value formats.
The boundary should parse and validate:
def parse_product_id(value: object) -> str:
if not isinstance(value, str):
raise ValueError("product_id must be a string")
normalized = value.strip()
if not normalized:
raise ValueError("product_id must not be empty")
return normalized
def parse_quantity(value: object) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError("quantity must be an integer")
if value <= 0:
raise ValueError("quantity must be positive")
return value
def handle_request(payload: dict[str, object]) -> None:
try:
raw_quantity = payload["quantity"]
raw_product_id = payload["product_id"]
except KeyError as error:
raise ValueError(f"Missing required field: {error.args[0]}") from error
command = CreateOrderCommand(
quantity=parse_quantity(raw_quantity),
product_id=parse_product_id(raw_product_id),
)
order_service.create(command)
27. Chapter checkpoint
You are ready to continue when you can explain:
- Why Python is dynamically and strongly typed.
- Why annotations do not normally enforce runtime values.
- The difference between
Anyandobject. - Why static checking and runtime validation solve different problems.
- How protocols provide structural subtyping.
- Why external JSON should be parsed into strong internal models.
- Why
castdoes not convert data. - Why
boolcan surprise integer validation.