Whetstone.
Types, HonestlyType hints, and what they do not do
Module 3 · Lesson 115 min

Type hints, and what they do not do

Python has type hints. They look like TypeScript, they read like TypeScript, and the thing they promise is meaningfully weaker. Being precise about how weaker is the point of this lesson, because the gap is where the bugs live.

The syntax, which will feel like home

Annotations, on everything
def retry(url: str, attempts: int = 3, backoff: float = 1.5) -> bool:
    ...

names: list[str] = []
config: dict[str, int] = {}
pair: tuple[str, int] = ("a", 1)
maybe: str | None = None
handler: Callable[[str], None] = print

Generics use the built-in names directly (list[str], dict[str, int]), which is modern style since 3.9. Older code imports List and Dict from typing and means exactly the same thing; you will read plenty of it and you should not write any more of it.

Unions use | since 3.10. Optional[X] is X | None and Union[A, B] is A | B, both spelled the old way.

The thing that is different, stated precisely

Annotations are recorded and then ignored. The interpreter stores them in __annotations__ and does nothing else with them. No check, no coercion, no warning.

This runs. It does not even complain.
def double(n: int) -> int:
    return n * 2

double("ha")     # 'haha'

Now, TypeScript’s types are also erased at runtime, so the naive comparison says these are the same situation. They are not, and the difference is in the pipeline rather than in the type system.

TypeScript’s compiler is a required step. To get JavaScript out, you run tsc, and code that does not typecheck does not normally ship. The type system is not enforced at runtime, but it is enforced at build time, and there is a build.

Python has no required step. The checker is an external tool you may or may not have installed, may or may not run, and may or may not have wired into CI. So the honest question about a Python codebase is never “is it typed”, it is “is a checker run, and does it pass”. A repo full of beautiful annotations that nobody checks has documentation. Good documentation, worth having, but not a guarantee.

Any is any. object is unknown.

Two top types, two very different behaviours
def loose(x: Any) -> None:
    x.whatever().at_all()      # fine. Checking is off for x.

def strict(x: object) -> None:
    x.whatever()               # ERROR. object has no such attribute.
    if isinstance(x, Sender):
        x.whatever()           # fine. Narrowed.

Same advice as TypeScript: prefer the one that forces you to narrow. Any is the escape hatch and every use of it is a small hole. object is the honest top type.

Narrowing works the way you expect. isinstance checks, is None checks, and truthiness checks all narrow, and both major checkers understand them.

Type aliases and generics

Modern syntax, 3.12 and later
type UserId = str                          # a type alias, PEP 695
type Handler = Callable[[str], None]

def first[T](items: list[T]) -> T | None:  # a generic function
    return items[0] if items else None

class Box[T]:                              # a generic class
    def __init__(self, value: T) -> None:
        self.value = value

That syntax is new and clean. Code written before 3.12 uses TypeVar explicitly and a TypeAlias annotation, which is noisier and means the same thing:

The older spelling, which you will still read constantly
from typing import TypeVar, TypeAlias

UserId: TypeAlias = str
T = TypeVar("T")

def first(items: list[T]) -> T | None: ...

Both are current in the sense that both work. The old one dominates existing code because most libraries still support older Python versions.

Literal, Final, and the small useful ones

Worth knowing on sight
from typing import Literal, Final, TypedDict, NoReturn

Mode = Literal["fast", "careful"]     # a string-literal union. Same idea as TS.
MAX: Final = 10                       # a constant. The checker enforces it; nothing else does.
def die(msg: str) -> NoReturn: ...    # this function never returns normally

Literal is the one you will use most, because it is how a library says this argument is one of these four strings without inventing an enum.

Annotations at runtime, which matters more than you would expect

Some libraries genuinely read annotations at runtime and build behaviour from them. Pydantic does, and so does every framework that derives a schema from a function signature, which in your near future means LangChain tool definitions and FastAPI route handlers.

This used to be fragile. Annotations were evaluated eagerly at definition time, so a forward reference to a class defined later in the file had to be written as a string. from __future__ import annotations (PEP 563) fixed that by making all annotations strings, which fixed forward references and broke every library that wanted the actual objects.

Python 3.14 resolves it properly with PEP 649: annotations are now evaluated lazily, on demand, via annotationlib. Forward references work without quoting, and libraries that need the real values can still get them.

What to actually do

  1. Annotate every function signature. Parameters and return. Local variables mostly infer fine and annotating them is noise.
  2. Run a checker in CI, or accept that your annotations are comments. uv add --dev pyright and one CI line.
  3. Treat Any as a hole you are choosing, the same way you treat any.
  4. Check whether a dependency ships types before being surprised that nothing is checked downstream of it.

Where this goes

Annotations describe shapes. The next lesson is the three ways Python actually builds a structured shape, which is where your interface reflex needs redirecting: TypedDict, dataclass, and Protocol. All three are the same idea wearing very different clothes, and picking the wrong one is the most common structural mistake a TypeScript developer makes here.

Practice

Try it yourself

Quiz

Passing the wrong type

A function is annotated def send(count: int) -> None: and you call send("three") with no type checker in the loop. What does the interpreter do?

  1. AIt runs the function with a string, and fails only if the body does something a string cannot do
  2. BIt raises TypeError at the call, because the annotation is checked when arguments are bound
  3. CIt coerces the argument to int where possible, and raises ValueError when the conversion fails
  4. DIt emits a runtime warning naming the mismatch and continues with the value unchanged
Show answer

Correct answer: A — It runs the function with a string, and fails only if the body does something a string cannot do

Annotations are recorded and otherwise ignored by the interpreter. Nothing checks them, nothing coerces, nothing warns. This is the load-bearing difference from TypeScript, where the guarantee is also erased at runtime but nothing UNCHECKED can reach production because compilation is a required step. In Python, running a type checker is optional, so an unchecked repo has annotations that are documentation and nothing more. The runtime-warning option is the most seductive because a warning feels like a reasonable middle ground the language might have chosen. It did not.

Quiz

What Optional actually means

A parameter is annotated timeout: Optional[int]. What does that tell you about calling the function?

  1. AThat the argument can be omitted, and defaults to None when it is
  2. BThat the value may be None, and nothing at all about whether the argument can be omitted
  3. CThat the argument can be omitted, and that passing None explicitly is rejected
  4. DThat the value may be None and that the parameter therefore has an implicit None default
Show answer

Correct answer: B — That the value may be None, and nothing at all about whether the argument can be omitted

Optional[int] means exactly int | None and says nothing about omissibility. Whether an argument can be omitted is decided solely by whether it has a default value. The name is a genuine historical misnomer and is the reason the modern style prefers int | None, which cannot be misread. The implicit-default option is the specific wrong belief worth catching: Optional does not supply a default, so def f(x: Optional[int]) requires an argument and f() is a TypeError. You need = None written out to get the behaviour the name implies.

Quiz

Any against object

You need an annotation for a parameter that could genuinely be anything. What is the difference between Any and object?

  1. Aobject disables checking on the value; Any restricts it to the small set of universally supported operations
  2. BThey are equivalent to the checker, and object is simply the older spelling from before typing existed
  3. CAny disables checking on the value entirely; object accepts anything but permits only operations every object supports
  4. DAny accepts anything at the boundary but narrows to the concrete runtime type on first use inside the function
Show answer

Correct answer: C — Any disables checking on the value entirely; object accepts anything but permits only operations every object supports

Any is an escape hatch that switches the checker off for that value, so every attribute access and every call on it is permitted and unverified. object is the honest top type: it accepts any value and then lets you do almost nothing without narrowing first, which is exactly TypeScript's unknown. The mapping worth memorising is Any is any and object is unknown, and the same advice applies: reach for the one that forces you to narrow. The narrows-on-first-use option states the pair backwards, which is the mistake people actually make.

Recall

The honest comparison

Worth being able to say out loud, because the difference is one of degree and one of kind and people conflate them.

Type hints and TypeScript types are both erased at runtime. So what is the substantive difference in the guarantee each gives you?

Reveal answer

The erasure is the same; the enforcement is not. TypeScript's compiler is a required step in producing runnable output, so code that does not typecheck normally never ships. Python's checkers are optional external tools, so a repo with annotations and no checker in CI has documentation rather than a guarantee, and a repo with a checker still has holes wherever Any leaks in from an untyped dependency. Two practical consequences: the honest question about a Python codebase is not is it typed but is a checker run and does it pass, and untyped third-party packages silently degrade the checking of everything they touch, which is why py.typed markers and stub packages matter.

Check

Turn on a checker and read the first failure

In any Python project you can reach, uv add --dev pyright and then uv run pyright. Read the first error rather than fixing it.

You should see

You can say whether the error is about your own code or about an untyped dependency leaking Any, and you have noticed how much the checker inferred without any annotations present.

Sign in to track your progress →