Zum Hauptinhalt springen

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 Any is similar to TypeScript any;
  • Python object is not identical to unknown, 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

TermMeaning
dynamic typingtypes are associated with runtime objects
static analysischecking code without executing every path
type annotationmetadata describing an expected type
inferencederiving a type from context
narrowingreducing a union to a more specific type
structural typingcompatibility based on shape or behaviour
nominal typingcompatibility based on declared identity
runtime validationchecking real input while the program runs
type erasureremoval of static type information before runtime
escape hatcha 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 Any here 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:

  1. Why Python is dynamically and strongly typed.
  2. Why annotations do not normally enforce runtime values.
  3. The difference between Any and object.
  4. Why static checking and runtime validation solve different problems.
  5. How protocols provide structural subtyping.
  6. Why external JSON should be parsed into strong internal models.
  7. Why cast does not convert data.
  8. Why bool can surprise integer validation.