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
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] = printGenerics 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.
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.
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
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 = valueThat 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:
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
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 normallyLiteral 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
- Annotate every function signature. Parameters and return. Local variables mostly infer fine and annotating them is noise.
- Run a checker in CI, or accept that your annotations are comments.
uv add --dev pyrightand one CI line. - Treat
Anyas a hole you are choosing, the same way you treatany. - 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.
Try it yourself
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?
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.
What Optional actually means
A parameter is annotated timeout: Optional[int]. What does that tell you about calling the function?
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.
Any against object
You need an annotation for a parameter that could genuinely be anything. What is the difference between Any and object?
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.
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.
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 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.