Zum Hauptinhalt springen

Chapter 17 — Modern Type Annotations and Type Narrowing

1. Opening problem

Consider:

def find_user(
user_id: int,
) -> User | None:
...

The annotation communicates that the function may return a User or None.

It does not:

  • query the database;
  • verify the returned runtime type;
  • prevent another function from returning a dictionary;
  • validate external request data;
  • automatically raise an error when a caller ignores None.

Static typing is a design and analysis system layered onto Python's dynamic runtime.

The central question is not:

How can I make Python behave exactly like TypeScript?

It is:

Which contracts should static analysis describe, and which guarantees require runtime validation or stronger domain design?


2. Annotations are not runtime enforcement

def repeat(
value: str,
count: int,
) -> str:
return value * count

Python still executes:

print(
repeat([1, 2], 2)
)

The result is a repeated list.

A type checker should report an incompatible call, but the Python interpreter normally evaluates the actual objects.

Annotations can be inspected:

print(
repeat.__annotations__
)

Frameworks may choose to interpret annotations for validation, serialization, dependency injection, or documentation. That behavior belongs to the framework, not to ordinary annotation semantics.


3. Type aliases

Modern syntax:

type UserId = int
type Headers = dict[str, str]
type JsonScalar = (
str
| int
| float
| bool
| None
)

A type alias improves readability but does not necessarily create a new distinct type.

type UserId = int
type ProductId = int

A type checker generally treats both as integers.

When accidental mixing is dangerous, use a value object or nominal helper such as NewType.


4. NewType

from typing import NewType

UserId = NewType(
"UserId",
int,
)

ProductId = NewType(
"ProductId",
int,
)

Static tools distinguish them:

def load_user(
user_id: UserId,
) -> User:
...

At runtime:

user_id = UserId(42)
print(type(user_id))

The value is still an integer.

NewType provides static nominal distinction with nearly zero runtime behavior.

Use a value-object class when you need:

  • validation;
  • normalization;
  • methods;
  • safe representation;
  • runtime distinction;
  • domain behavior.

5. Union types

def normalize_identifier(
value: int | str,
) -> str:
if isinstance(
value,
int,
):
return str(value)

return value.strip()

The type checker narrows value in each branch.

A union should represent a coherent set of supported states.

Warning sign:

def process(
value: (
User
| Order
| Invoice
| str
| bytes
| None
),
) -> object:
...

A large unrelated union can indicate a missing abstraction or confused responsibility.


6. Optional values

These are equivalent:

User | None
from typing import Optional

Optional[User]

Modern Python commonly prefers User | None.

Use None only when absence is part of the contract.

Do not return None for every possible error. Distinguish:

  • not found;
  • invalid input;
  • unauthorized access;
  • infrastructure failure;
  • conflict;
  • expected absence.

Possible designs include:

  • None;
  • exceptions;
  • result unions;
  • domain-specific result objects.

7. Literal types

from typing import Literal

LogLevel = Literal[
"debug",
"info",
"warning",
"error",
]


def configure_logging(
level: LogLevel,
) -> None:
...

A type checker can reject unsupported string literals.

Runtime validation is still necessary at untrusted boundaries:

VALID_LEVELS = {
"debug",
"info",
"warning",
"error",
}


def parse_log_level(
value: object,
) -> LogLevel:
if not isinstance(
value,
str,
):
raise ValueError(
"level must be a string"
)

normalized = (
value.strip().lower()
)

if normalized not in VALID_LEVELS:
raise ValueError(
f"Unsupported level: "
f"{normalized}"
)

return normalized

Some type checkers may require a cast or a membership structure whose type is more precise.


8. Enums versus literals

Literal:

Status = Literal[
"pending",
"active",
"completed",
]

Enum:

from enum import Enum


class Status(Enum):
PENDING = "pending"
ACTIVE = "active"
COMPLETED = "completed"

Use literals when:

  • transport values are naturally strings;
  • the set is small;
  • no behavior is needed;
  • direct JSON compatibility is useful.

Use enums when:

  • runtime identity matters;
  • methods or metadata are useful;
  • iteration over members matters;
  • invalid construction should fail;
  • values should not behave as arbitrary strings.

Avoid converting every two-value option into an enum if a boolean or literal is clearer.


9. Final

from typing import Final

MAX_ATTEMPTS: Final = 3

Static tools should report reassignment:

MAX_ATTEMPTS = 10

Final is not runtime immutability.

A mutable final object can still change:

DEFAULT_ROLES: Final = [
"reader",
]

DEFAULT_ROLES.append(
"admin"
)

The name is final; the list is not immutable.

Prefer immutable values:

DEFAULT_ROLES: Final = (
"reader",
)

10. ClassVar

from typing import ClassVar


class User:
table_name: ClassVar[str] = (
"users"
)
email: str

ClassVar tells type checkers and dataclass machinery that a name belongs to the class rather than each instance.

Dataclass example:

from dataclasses import dataclass


@dataclass
class User:
table_name: ClassVar[str] = (
"users"
)
email: str = ""

table_name is not a dataclass field.


11. Self

from typing import Self


class Query:
def where(
self,
condition: str,
) -> Self:
...
return self

Self preserves subclass return types.

class UserQuery(Query):
def active(self) -> Self:
return self.where(
"active = true"
)

TypeScript's polymorphic this type serves a similar purpose.

Use Self for:

  • fluent methods;
  • alternative constructors returning the current class;
  • clone-like operations;
  • protocol methods returning the implementer type.

Do not use Self when the function always returns one concrete base type.


12. Callable annotations

Simple callback:

from collections.abc import Callable

Transformer = Callable[
[str],
str,
]

Callbacks with named parameters are better described by a protocol:

from typing import Protocol


class Reporter(Protocol):
def __call__(
self,
message: str,
*,
urgent: bool = False,
) -> None:
...

Callable[[str, bool], None] cannot communicate keyword-only semantics or parameter names.


13. Annotated

from typing import Annotated


UserName = Annotated[
str,
"non-empty",
"maximum length 100",
]

Static type checkers generally treat this as str unless they understand the metadata.

Frameworks or custom tools may interpret metadata.

A richer metadata object:

from dataclasses import dataclass


@dataclass(frozen=True)
class MaxLength:
value: int


UserName = Annotated[
str,
MaxLength(100),
]

Use Annotated when:

  • the base static type remains accurate;
  • additional metadata belongs to a framework or tool;
  • metadata consumers are documented.

Do not assume annotations validate themselves.


14. Never and unreachable code

from typing import Never


def fail(
message: str,
) -> Never:
raise RuntimeError(message)

A function returning Never does not complete normally.

This helps control-flow analysis.

def load_required(
value: str | None,
) -> str:
if value is None:
fail("Missing value")

return value

The checker understands that execution after fail continues only when value is a string.


15. Exhaustiveness with assert_never

from typing import (
Literal,
assert_never,
)

Status = Literal[
"pending",
"active",
"completed",
]


def status_label(
status: Status,
) -> str:
match status:
case "pending":
return "Pending"
case "active":
return "Active"
case "completed":
return "Completed"
case _ as unreachable:
assert_never(
unreachable
)

If a new literal is added but not handled, strict type checking can report the reachable assert_never.

At runtime, assert_never raises if reached.

This is useful for union-driven domain states.


16. Type narrowing

Built-in narrowing tools include:

  • is None;
  • isinstance;
  • issubclass;
  • literal comparisons;
  • pattern matching;
  • truth checks in suitable cases;
  • assertions;
  • user-defined predicates.

Example:

def render(
value: str | bytes | None,
) -> str:
if value is None:
return ""

if isinstance(
value,
bytes,
):
return value.decode(
"utf-8"
)

return value

A type checker follows control flow and narrows the remaining possibilities.


17. TypeIs

TypeIs describes a user-defined type predicate.

from typing import TypeIs


def is_string(
value: object,
) -> TypeIs[str]:
return isinstance(
value,
str,
)

Usage:

value: str | int

if is_string(value):
reveal_type(value)
else:
reveal_type(value)

A capable type checker narrows both the true and false branches.

The predicate must be logically correct.

Unsound:

def is_string(
value: object,
) -> TypeIs[str]:
return True

Static tools trust the declared predicate contract. Incorrect predicates can make type checking unsound.


18. TypeGuard

from typing import TypeGuard


def is_string_list(
values: list[object],
) -> TypeGuard[list[str]]:
return all(
isinstance(value, str)
for value in values
)

Why not TypeIs[list[str]]?

Mutable list is invariant. list[str] is not a subtype of list[object].

TypeGuard can express certain narrowing relationships that TypeIs cannot, but it generally narrows only the true branch and can replace the original type more aggressively.

Prefer TypeIs when the narrowed type is a genuine subtype of the input type.

Use TypeGuard for carefully justified cases such as invariant mutable collections.


19. Assertions and casts

Assertion:

assert isinstance(
value,
User,
)

This checks at runtime and narrows statically.

Cast:

from typing import cast

user = cast(
User,
value,
)

cast does not check or convert anything. It returns the object unchanged.

Use cast when a real invariant exists but the checker cannot infer it.

Do not use it to silence warnings about unvalidated data.


20. Annotation introspection

from typing import (
get_args,
get_origin,
get_type_hints,
)

Example:

hints = get_type_hints(
function,
include_extras=True,
)

Caution:

  • annotations may contain forward references;
  • evaluation can import names;
  • introspection can fail;
  • annotation evaluation can execute code in some situations;
  • framework security and trust boundaries matter.

Do not evaluate arbitrary untrusted annotations.


21. Type-checking-only imports

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from expensive_module import User

This import runs for static checking but not during ordinary execution.

Use cases:

  • avoid runtime cycles;
  • avoid expensive optional imports;
  • type framework integrations.

Risks:

  • runtime introspection may not resolve the name;
  • forward annotations may need careful handling;
  • hiding every import can make architecture unclear.

Fix real circular dependencies rather than automatically moving everything under TYPE_CHECKING.


22. TypeScript comparison

TypeScript types are erased during compilation.

Python annotations remain available as metadata, although their evaluation and representation vary by version and context.

Both systems provide:

  • unions;
  • literals;
  • generics;
  • narrowing;
  • callable types;
  • nominal helper techniques;
  • exhaustive checks.

Important differences:

  • Python remains dynamically typed at runtime;
  • annotations can be introspected;
  • frameworks may use annotations;
  • None is a runtime singleton;
  • Any and cast are deliberate static escape hatches;
  • Python type-checker behavior is not one single compiler implementation.

23. Common mistakes

Treating hints as validation

Validate untrusted data.

Large unrelated unions

Refactor the abstraction.

Final with mutable values

The object can still mutate.

cast after JSON loading

Parse instead.

Incorrect TypeIs

The checker may trust a lie.

Annotated without a consumer

Metadata has no effect by itself.

Excessive TYPE_CHECKING

Resolve architecture where possible.

Automatic runtime introspection

Consider security and import behavior.


24. English vocabulary

TermMeaning
annotationmetadata describing an expected type
unionvalue allowed to have one of several types
narrowingreducing possibilities through control flow
exhaustivecovering every possible case
predicatefunction answering a condition
nominal distinctiondifference based on declared type identity
static escape hatchfeature bypassing normal checking
metadataadditional descriptive information
unreachablecode path that should never execute
soundnesstype conclusions matching possible runtime behavior

Useful sentences:

  • “The annotation documents the contract but performs no runtime validation.”
  • “The type predicate must be logically sound.”
  • Final prevents rebinding statically, not mutation.”
  • “The exhaustive branch will fail type checking when a new state is added.”
  • “A value object is preferable because runtime validation is required.”
  • “The cast hides uncertainty instead of resolving it.”

25. Speaking task

Explain for ten minutes:

How Python type narrowing differs from runtime validation.

Include isinstance, TypeIs, TypeGuard, cast, and assert_never.


26. Writing task

Write a 450-word review of a Python API that uses cast() on every request field, returns Any, and relies on type hints as its only validation layer.


27. Exercises

Exercise 1

Model an order status with literals and write an exhaustive label function.

Exercise 2

Create a NewType for UserId and compare it with a dataclass value object.

Exercise 3

Write a sound TypeIs predicate for User.

Exercise 4

Write a TypeGuard validating list[object] as list[str].

Exercise 5

Use Self in a fluent query builder.

Exercise 6

Create an Annotated type with structured length metadata and inspect it.


28. Complete solutions

Solution 1

from typing import (
Literal,
assert_never,
)

OrderStatus = Literal[
"created",
"paid",
"shipped",
"cancelled",
]


def order_status_label(
status: OrderStatus,
) -> str:
match status:
case "created":
return "Created"
case "paid":
return "Paid"
case "shipped":
return "Shipped"
case "cancelled":
return "Cancelled"
case _ as unreachable:
assert_never(
unreachable
)

Solution 2

from typing import NewType

UserId = NewType(
"UserId",
int,
)

This provides static distinction but no runtime validation.

Value-object alternative:

from dataclasses import dataclass


@dataclass(frozen=True)
class UserIdValue:
value: int

def __post_init__(
self,
) -> None:
if self.value <= 0:
raise ValueError(
"User ID must be positive"
)

Use NewType for lightweight static distinction. Use the dataclass when runtime invariants or behavior matter.

Solution 3

from typing import TypeIs


def is_user(
value: object,
) -> TypeIs[User]:
return isinstance(
value,
User,
)

Solution 4

from typing import TypeGuard


def is_string_list(
values: list[object],
) -> TypeGuard[list[str]]:
return all(
isinstance(value, str)
for value in values
)

Be cautious after narrowing a mutable list if aliases can insert incompatible values.

Solution 5

from typing import Self


class Query:
def __init__(self) -> None:
self._conditions: list[str] = []

def where(
self,
condition: str,
) -> Self:
self._conditions.append(
condition
)
return self

def limit(
self,
amount: int,
) -> Self:
if amount <= 0:
raise ValueError(
"limit must be positive"
)

return self

Solution 6

from dataclasses import dataclass
from typing import (
Annotated,
get_type_hints,
)


@dataclass(frozen=True)
class MaxLength:
value: int


UserName = Annotated[
str,
MaxLength(100),
]


def greet(
name: UserName,
) -> str:
return f"Hello, {name}"


hints = get_type_hints(
greet,
include_extras=True,
)

print(hints["name"])
print(
hints["name"].__metadata__
)

No validation occurs unless code interprets the metadata.


29. Chapter checkpoint

You should now be able to explain:

  1. aliases and NewType;
  2. unions and optionals;
  3. literals and enums;
  4. Final and ClassVar;
  5. Self;
  6. callable protocols;
  7. Annotated;
  8. Never and assert_never;
  9. narrowing;
  10. TypeIs versus TypeGuard;
  11. assertions versus casts;
  12. annotation introspection.