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;
Noneis a runtime singleton;Anyandcastare 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
| Term | Meaning |
|---|---|
| annotation | metadata describing an expected type |
| union | value allowed to have one of several types |
| narrowing | reducing possibilities through control flow |
| exhaustive | covering every possible case |
| predicate | function answering a condition |
| nominal distinction | difference based on declared type identity |
| static escape hatch | feature bypassing normal checking |
| metadata | additional descriptive information |
| unreachable | code path that should never execute |
| soundness | type conclusions matching possible runtime behavior |
Useful sentences:
- “The annotation documents the contract but performs no runtime validation.”
- “The type predicate must be logically sound.”
- “
Finalprevents 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:
- aliases and
NewType; - unions and optionals;
- literals and enums;
FinalandClassVar;Self;- callable protocols;
Annotated;Neverandassert_never;- narrowing;
TypeIsversusTypeGuard;- assertions versus casts;
- annotation introspection.