PythonMastery
advanced 18 min read · lesson 6 of 9 in Python Advanced

Type Hints: Industrial-Grade Python

1 · The lesson

read

Python is dynamic. At runtime, def f(x: int) -> str: accepts a list and returns None without complaint. The interpreter ignores annotations entirely.

So why bother? Because a static type checker — mypy, pyright, or whatever your editor runs in the background — reads those annotations and tells you, before you ship, that f is being called with a list. IDE autocomplete becomes accurate. Refactors become safe. The class of bugs where a None slips through eight call sites and crashes in production simply stops existing.

This lesson is the working vocabulary you need to type a real codebase: built-in generics, unions, Optional, Callable, TypeVar, Protocol, TypedDict, Literal, NewType, plus the workflow around mypy/pyright and the gradual-typing mindset that keeps the project tractable.


1. Why Bother — The Static Win

Annotations buy you four things, none of them at runtime:

1. Documentation that can't go stale — the signature is the docstring.
2. IDE autocomplete and go-to-definition that actually work on your dynamic code.
3. Refactoring safety — rename a field, the checker tells you every site that needs updating.
4. Bug detection — None leaks, wrong-type arguments, forgotten return paths.

A concrete example. This 10-line function looks fine:

python
def first_name(user):
    return user["name"].split()[0]

Type it, and the checker finds two real bugs:

python
from typing import Optional

def first_name(user: Optional[dict]) -> str:
    return user["name"].split()[0]
    #      ^ error: Item "None" of "Optional[dict]" has no attribute "__getitem__"
    #              ^ error: "name" not guaranteed to exist on dict

The fix is to handle None and to type the dict more precisely (TypedDict, section 10). The point isn't that you couldn't have spotted these without types — it's that the checker spots them on every change, automatically, without you reading the code.


2. Recap — Basic Syntax

You met this in functions:

python
def greet(name: str, times: int = 1) -> str:
    return ("Hello, " + name + "!\n") * times

count: int = 0
ratio: float = 0.5
done: bool = False
names: list[str] = []

Three places annotations appear: function parameters, function return, and variables. The colon-syntax is the same in all three. Defaults still work normally — the annotation goes before the =.


3. Built-In Generics (3.9+)

Pre-3.9 needed imports from typing:

python
from typing import List, Dict, Tuple, Set       # the old way
def fn(xs: List[int]) -> Dict[str, int]: ...

In 3.9+, use the built-in types directly — they're subscriptable now:

python
def fn(xs: list[int]) -> dict[str, int]: ...
coords: tuple[float, float, float] = (1.0, 2.0, 3.0)
tags: set[str] = {"alpha", "beta"}

Both still work, but the lowercase form is canonical. Pre-3.9 codebases are the only place you should see List/Dict/Tuple in new code.

tuple has two flavours:

python
point: tuple[int, int]                          # exactly two ints
coords: tuple[int, ...]                         # variable-length tuple of ints

The ... (literal ellipsis) is the syntax for "homogeneous variable-length tuple".


4. Optional[X] and Union — The | Form

Pre-3.10:

python
from typing import Optional, Union

def lookup(key: str) -> Optional[int]: ...
def parse(x: Union[str, bytes]) -> int: ...

3.10+ ships PEP 604 syntax — write unions with |:

python
def lookup(key: str) -> int | None: ...
def parse(x: str | bytes) -> int: ...

Optional[X] is literally X | None. Use | in new code; it reads better and doesn't need an import.

Watch the meaning: Optional[int] (or int | None) means "the value can be int or None." It doesn't mean "the argument is optional" — that's arg: int = 0. The two often coincide; they're not the same thing.


5. Any — The Escape Hatch

Any opts out of type checking. Anything goes in, anything comes out.

python
from typing import Any

def parse_json(s: str) -> Any:                  # we don't know the shape
    return json.loads(s)
+ setup added so this can run · defines json
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

json = _AutoMock('json')

Use it when:

  • You're typing a boundary with truly unknown shape (raw JSON, pickle.load).
  • You're gradually adding types and haven't gotten to a section yet.

Don't use it as a default — it silently disables every check. Every Any in your code is a place the type checker can't help you. Prefer object (which forces explicit narrowing) when you can.


6. Callable[...] — Function Signatures as Types

When a parameter is itself a function:

python
from typing import Callable

def apply(fn: Callable[[int, int], int], a: int, b: int) -> int:
    return fn(a, b)

apply(lambda x, y: x + y, 2, 3)                 # 5

Pattern: Callable[[arg_types], return_type]. The double brackets aren't a typo — the first list is the argument types as a Python list literal.

For a no-argument callable returning a string: Callable[[], str]. For "any callable, don't check the signature": Callable[..., int] (literal ellipsis).


7. Generics — TypeVar and PEP 695

A generic function works with any type, but its input and output are linked. first(items: list[T]) -> T says: whatever the list contains, that's what comes out.

Pre-3.12 form — declare a TypeVar first:

python
from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

x = first([1, 2, 3])                            # checker infers T=int → x: int
y = first(["a", "b"])                           # checker infers T=str → y: str

3.12+ form — PEP 695 inline syntax, no TypeVar import:

python
def first[T](items: list[T]) -> T:
    return items[0]
+ setup added so this can run · defines T
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

T = _AutoMock('T')

Same meaning, less ceremony. The [T] after the function name introduces the type parameter for that scope.

Classes work the same way:

python
# Pre-3.12
from typing import Generic, TypeVar
T = TypeVar("T")

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []
    def push(self, x: T) -> None: self._items.append(x)
    def pop(self) -> T: return self._items.pop()

# 3.12+
class Stack[T]:
    def __init__(self) -> None:
        self._items: list[T] = []
    def push(self, x: T) -> None: self._items.append(x)
    def pop(self) -> T: return self._items.pop()

You can constrain or bound a TypeVar:

python
from typing import TypeVar
Number = TypeVar("Number", int, float)          # exactly int or float
Comparable = TypeVar("Comparable", bound="SupportsLt")  # any subtype of SupportsLt

PEP 695 syntax: def f[T: SupportsLt](x: T) -> T: for the bound form.


8. Protocol — Structural Typing

Python's whole tradition is duck typing: if it has .read(), treat it as a file. Protocol brings that into the type system without requiring an inheritance hierarchy.

python
from typing import Protocol

class SupportsClose(Protocol):
    def close(self) -> None: ...

def safely_close(resource: SupportsClose) -> None:
    resource.close()

Any class with a close(self) -> None method satisfies SupportsClose — no class MyFile(SupportsClose): required. The checker matches on shape, not declared ancestry.

This is the right tool when:

  • You're typing a parameter that accepts "anything that quacks like X."
  • You can't (or don't want to) edit the third-party class to inherit from your interface.

The stdlib typing module ships pre-baked protocols: SupportsInt, SupportsFloat, Iterable[T], Iterator[T], Sized, Hashable. Use them when they fit.


9. TypedDict — Dict-Shaped Records

Sometimes the data really is a dict — JSON payloads, wire formats, config blobs. You don't want to convert to a dataclass; you want type-checked dict access.

python
from typing import TypedDict

class User(TypedDict):
    id: int
    name: str
    email: str

def greet(u: User) -> str:
    return f"Hi {u['name']}"

u: User = {"id": 1, "name": "Bob", "email": "s@example.com"}
greet(u)                                        # OK
greet({"id": 1, "name": "x"})                   # checker error: missing 'email'

TypedDict is a static-check-only construct — at runtime, User instances are plain dicts. No isinstance, no validation, no __init__. It's how Pydantic-free codebases type their JSON.

For partial dicts, declare optional keys:

python
class User(TypedDict, total=False):             # all keys optional
    id: int
    name: str

class User(TypedDict):                          # mix required + optional (3.11+)
    id: int                                     # required
    name: NotRequired[str]                      # from typing
+ setup added so this can run · defines TypedDict, NotRequired
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

TypedDict = _AutoMock('TypedDict')
NotRequired = _AutoMock('NotRequired')

Compare with @dataclass (dataclasses) — same fields, different trade-offs:

TypedDict@dataclass
At runtimeA dictA real instance
Construction{"id": 1, ...}User(id=1, ...)
ValidationNone (static only)__post_init__ if you write it
Best forJSON / wire formatsDomain objects with methods

10. Literal[...] — Enum-Lite

When a parameter accepts only a fixed set of string or int values:

python
from typing import Literal

Colour = Literal["red", "green", "blue"]

def paint(c: Colour) -> None: ...

paint("red")                                    # OK
paint("purple")                                 # checker error: not in literal set

Lighter than a full enum.Enum when the values are already strings or ints in your data. Combines naturally with discriminated unions for things like {"kind": "circle", "radius": 1.0} vs {"kind": "square", "side": 2.0}.


11. Final and NewType

Final says "do not reassign":

python
from typing import Final

MAX_RETRIES: Final = 3
MAX_RETRIES = 5                                 # checker error: cannot assign to Final

Useful for constants you want loudly protected from accidental rebinding.

NewType creates a distinct type at the static-check level — same runtime representation, different to the checker:

python
from typing import NewType

UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)

def get_user(uid: UserId) -> dict: ...

uid = UserId(42)
oid = OrderId(99)

get_user(uid)                                   # OK
get_user(oid)                                   # checker error: OrderId is not UserId
get_user(42)                                    # checker error: plain int is not UserId

Catches the classic bug of passing an order_id where a user_id was expected. At runtime, UserId(42) == 42 is True — it's purely a type-checker fiction.


12. ClassVar — Class-Level Fields in Dataclasses

In a @dataclass, every annotated attribute becomes an instance field by default. ClassVar opts out — keeps the attribute on the class, not the instance:

python
from dataclasses import dataclass
from typing import ClassVar

@dataclass
class Connection:
    host: str
    port: int
    default_timeout: ClassVar[int] = 30         # shared across all instances

c = Connection("db", 5432)
print(c.default_timeout)                        # 30 (class-level)
Connection.default_timeout = 60                 # change for all instances

Without ClassVar, the dataclass would generate default_timeout as an __init__ parameter — which isn't what you want for a shared default.


13. Forward References

You sometimes need to reference a type before it's defined — recursive types, mutually referencing classes. Use a string:

python
class Tree:
    def __init__(self, value: int, children: "list[Tree]" = None) -> None:
        self.value = value
        self.children = children or []

Or, cleaner, enable PEP 563 lazy annotations for the whole file:

python
from __future__ import annotations              # at the top of the file

class Tree:
    def __init__(self, value: int, children: list[Tree] | None = None) -> None:
        self.value = value
        self.children = children or []

from __future__ import annotations makes every annotation a string under the hood — they're evaluated lazily (or never, by the checker only). Side effect: forward references just work, and you can use list[int] / X | None on pre-3.10 runtimes. Cost: anything that introspects annotations at runtime (Pydantic, FastAPI, attrs) needs to call get_type_hints() to resolve them. In a typical app, that cost is invisible.


14. Running mypy / pyright

The checker is a separate tool. Install once:

bash
pip install mypy
mypy your_package/

A concrete bug it catches:

python
# users.py
def first_user_name(users: list[dict]) -> str:
    return users[0].get("name")                 # .get returns Optional[str]!
python
$ mypy users.py
users.py:2: error: Incompatible return value type (got "Optional[str]", expected "str")

The bug: dict.get returns None when the key is missing. The function claims to return str. The fix is either users[0]["name"] (raises on missing) or change the return type to str | None (caller handles it).

pyright (and its VS Code form, Pylance) is the same idea but faster and the default in many editors.

Strictness levels:

  • Default — lenient. Misses things; good for legacy codebases.
  • mypy --strict — turns on every check. Equivalent to enabling a couple of dozen individual flags.
  • # type: ignore[error-code] on a single line — local escape hatch. Always include the error code so it doesn't suppress unrelated errors that appear later.
python
result = library_call()                         # type: ignore[no-untyped-call]
+ setup added so this can run · defines library_call
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def library_call(*_a, **_kw):
    print('-> library_call() called')
    return _AutoMock('library_call()')

Use # type: ignore like Any: sparingly, with intent, and with a comment explaining why.


15. Annotations at Runtime

The interpreter stores annotations in __annotations__:

python
def fn(x: int, y: str = "") -> bool: ...

print(fn.__annotations__)
# {'x': <class 'int'>, 'y': <class 'str'>, 'return': <class 'bool'>}

With from __future__ import annotations, those become strings. Use typing.get_type_hints() to resolve them properly:

python
from typing import get_type_hints
print(get_type_hints(fn))                       # same dict, with strings evaluated
+ setup added so this can run · defines fn
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

fn = _AutoMock('fn')

This is how Pydantic and FastAPI work. They read your annotations at class definition time and build validators/serialisers/OpenAPI schemas from them. Your type hints stop being decoration and start being the contract:

python
from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str
    email: str

User(id="1", name="Bob", email="x@y.z")      # Pydantic coerces "1" → 1; validates types

Same annotations the type checker reads, now enforced at runtime by the library. That's the modern Python stack in one move.


16. The Gradual-Typing Mindset

You don't have to type the whole codebase. Mypy/pyright are explicitly designed for gradual typing — partial annotations, mixed-typed and untyped code, file-by-file rollout. The practical rollout:

1. Start with the public API of each module — the functions other code calls. Wrong types here cause the most downstream pain.
2. Then boundary code — anything that reads JSON, parses input, talks to the network. These are where bad data sneaks in.
3. Then internal helpers as they cause friction or get touched.
4. Add mypy to CI lenient first, then ratchet strictness one flag at a time.

# type: ignore and Any are the safety valves. Use them to keep CI green while you work on the rest. Don't lacquer the codebase with Any to silence the checker — at that point you've got annotations without typing.


Common Mistakes

1. Dict[str, int] in 3.9+ code

Works, but dated. Lowercase dict[str, int] is canonical from 3.9 onward. Same for List, Tuple, Set, Type, FrozenSet.

2. Misreading Optional[X]

Optional[X] is X | None — the value can be None. It is not "this parameter is optional." Optional parameters (with defaults) and optional values (that can be None) are unrelated concepts that share a word.

python
def fn(x: Optional[int] = None) -> None: ...    # both: defaultable AND can be None
def fn(x: int = 0) -> None: ...                 # defaultable, NOT None-able
def fn(x: int | None) -> None: ...              # required, but can be None
+ setup added so this can run · defines Optional
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Optional = _AutoMock('Optional')

3. Forgetting Self (3.11+) for fluent builders

python
from typing import Self                         # 3.11+

class Builder:
    def with_name(self, n: str) -> Self:
        self.name = n
        return self

Self correctly types subclass returns. -> "Builder" would have a subclass's .with_name() return the base type — losing chaining type safety. Self is the right annotation for __enter__, copy(), and any builder method.

4. Any everywhere

python
def process(data: Any) -> Any: ...              # what was the point of types?
+ setup added so this can run · defines Any
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Any = _AutoMock('Any')

Any short-circuits the whole system. Replace with object (forces narrowing), a Protocol (structural), TypeVar (generic), or a specific union as appropriate. Each Any is a place the checker can't help.

5. Treating List[int] (capital L) as deprecated

It's not deprecated — both forms still work in 3.9+. The capital List is just the older spelling. Don't break old code over it; do prefer lowercase in new code.

6. Believing annotations enforce anything at runtime

python
def double(x: int) -> int:
    return x * 2

print(double("hi"))                             # 'hihi' — no TypeError, no warning

The interpreter does nothing with annotations. Static checkers, IDEs, and runtime libraries (Pydantic, FastAPI, attrs) use them; the interpreter ignores them. If you need runtime validation, reach for one of those libraries or add manual isinstance checks at the boundary.


🎯 Your Turn — Type an Untyped Function

Below is a generic-ish CSV-row parser. It works, but it's untyped — and the contract is fuzzy. Add full type hints.

Requirements:


  • The transform argument is a callable taking a str and returning a value of some type T. Use a TypeVar.

  • Missing values (empty strings in the row) should be None in the output, not run through transform. Use Optional / | None.

  • Type transform as a Protocol with a __call__ method, so any callable-with-the-right-shape satisfies it.

  • The function returns a list of TypedDict records — one dict per row, keyed by column name.

python
def parse_csv(rows, columns, transform):
    """Parse CSV rows into dicts; transform each non-empty cell."""
    results = []
    for row in rows:
        record = {}
        for col, value in zip(columns, row):
            if value == "":
                record[col] = None
            else:
                record[col] = transform(value)
        results.append(record)
    return results


# Example use — should type-check after you're done
rows    = [["1", "Bob", ""], ["2", "Ada", "ada@x.y"]]
columns = ["id", "name", "email"]

records = parse_csv(rows, columns, lambda s: s.strip())
# records → [{"id": "1", "name": "Bob", "email": None},
#            {"id": "2", "name": "Ada", "email": "ada@x.y"}]
Hint 1 — TypeVar for the transform T = TypeVar("T"). The transform takes a str and returns T. Each call to parse_csv infers a different T based on the callable you pass.
Hint 2 — Protocol for the callable class StringTransform(Protocol[T]): with a __call__(self, value: str) -> T: method. Then transform: StringTransform[T]. (You can also write it as Callable[[str], T] — both work; the Protocol is more explicit about being a single-method "stringy callable".)
Hint 3 — TypedDict can't have dynamic keys A real TypedDict has fixed keys at class-definition time. For the exercise, declare a TypedDict for your specific columns (id/name/email). In a fully generic version you'd return list[dict[str, T | None]], but the point of this exercise is to see TypedDict in action.
Show full solution
python
from typing import Protocol, TypeVar, TypedDict, Optional


T = TypeVar("T")


class StringTransform(Protocol[T]):
    """Any callable that takes a str and returns a T."""
    def __call__(self, value: str) -> T: ...


class Record(TypedDict):
    """A parsed CSV row with our known columns. None means the cell was empty."""
    id: Optional[str]
    name: Optional[str]
    email: Optional[str]


def parse_csv(
    rows: list[list[str]],
    columns: list[str],
    transform: StringTransform[T],
) -> list[dict[str, T | None]]:
    """Parse CSV rows into dicts; transform each non-empty cell.

    Empty strings become None (not passed through `transform`).
    """
    results: list[dict[str, T | None]] = []
    for row in rows:
        record: dict[str, T | None] = {}
        for col, value in zip(columns, row):
            record[col] = None if value == "" else transform(value)
        results.append(record)
    return results


# Demo — type checker happily infers T from the lambda
rows    = [["1", "Bob", ""], ["2", "Ada", "ada@x.y"]]
columns = ["id", "name", "email"]

# T = str — transform is `str -> str`
trimmed = parse_csv(rows, columns, lambda s: s.strip())
print(trimmed)
# [{'id': '1', 'name': 'Bob', 'email': None},
#  {'id': '2', 'name': 'Ada',   'email': 'ada@x.y'}]

# T = int — pass int() as transform, parse_csv returns list[dict[str, int | None]]
numeric = parse_csv([["1", "2"], ["3", ""]], ["a", "b"], int)
print(numeric)
# [{'a': 1, 'b': 2}, {'a': 3, 'b': None}]

What you ended up with:

  • A TypeVar that links input and output types — the checker tracks T across the call.
  • A Protocol that captures the single-method "callable from str" interface without forcing inheritance.
  • Optional / | None at the cell level — the empty-string sentinel becomes None, distinct from a transformed value.
  • A TypedDict capturing the specific shape of one parsed row, available to callers as a documented record type.

Run mypy on this file: zero errors, and any caller that passes a callable returning the wrong type, or indexes a row with the wrong column, gets caught immediately.

The fully-generic version (no Record TypedDict, just dict[str, T | None]) is more flexible but less self-documenting. In practice you'd use whichever matches the actual shape of the data — fixed schema → TypedDict, dynamic columns → dict.


What You Learned

  • Annotations are static documentation enforced by tools, not the interpreter. They earn their keep through mypy/pyright, IDE help, and runtime libraries like Pydantic/FastAPI.
  • Built-in generics (3.9+): list[int], dict[str, int], tuple[int, str]. Lowercase is canonical.
  • X | None (3.10+) replaces Optional[X]; A | B replaces Union[A, B].
  • Callable[[args], ret] types function-typed parameters.
  • Generics: TypeVar (pre-3.12) or PEP 695 def f[T](...) (3.12+) link inputs to outputs.
  • Protocol for structural typing — duck typing the checker can verify.
  • TypedDict for dict-shaped data with fixed keys; Literal for fixed value sets; NewType for distinct semantic types over a common runtime type; Final for "do not reassign".
  • from __future__ import annotations for free forward references and pre-3.10 union syntax.
  • # type: ignore[code] and Any are escape hatches — fine in moderation, lethal in volume.
  • Gradual typing: type the public API and the boundaries first; let the interior catch up over time.

Next: Performance — measuring before optimising, where Python is fast, where it isn't, and the toolkit (timeit, cProfile, dis) for finding out.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.