· 10 min read · Engineering
Type your Python the way you type your TypeScript

Most of the Python code I read on the internet is untyped. Most of the Python code I write is fully typed, and mypy runs in CI. Somewhere between those two facts is a habit I picked up from years of writing TypeScript, and I think a lot of Python developers would benefit from it.
This post is about how I actually type real Python — Django models, service functions, webhook payloads, background jobs. TypeScript gets a shorter section at the end because a) most people who write TypeScript already type it, and b) the interesting patterns show up in the Python side. If you don't write Python, skip to Part 2. If you don't write TypeScript, skip Part 2.
Why type Python at all
The counter-argument I hear most often is "Python is dynamic, that's the point". I don't buy it anymore. Two things changed my mind:
Types are documentation that stops lying. Every codebase over 5,000 lines has docstrings and comments that are subtly wrong. Nobody notices because Python doesn't care. A type annotation, checked by mypy in CI, forces the doc and the code to agree.
Types catch a specific class of bugs Django is bad at surfacing. Optional fields that get accessed unguarded. Serializer fields with the wrong write_only. Service functions called with the arguments in the wrong order. None returned from something that "should" return an object. This is boring, everyday Python code where a type checker would have flagged it in a second.
I resisted this for years. Then I spent six months on a large TypeScript codebase and got used to the compiler telling me exactly where I'd broken something. Coming back to untyped Python felt like losing a sense. I've been typing my Python rigorously ever since, and every project I start now has mypy configured before the first real commit.
Part 1 — Python
Set up mypy
Install mypy and the Django stubs. django-stubs teaches mypy about Django's ORM; djangorestframework-stubs covers DRF.
uv add --dev mypy django-stubs djangorestframework-stubsPut the config in pyproject.toml:
[tool.mypy]
python_version = "3.12"
plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"]
strict = true
warn_return_any = true
warn_unused_ignores = true
show_error_codes = true
exclude = ["migrations/", "\\.venv/"]
[[tool.mypy.overrides]]
module = ["*.migrations.*"]
ignore_errors = true
[tool.django-stubs]
django_settings_module = "config.settings"Run it:
uv run mypy .strict = true turns on every check. If you're retrofitting an existing codebase, that first run will produce a wall of errors. Two ways to handle that:
- New rule for new code. Add
[[tool.mypy.overrides]]for existing modules, setdisallow_untyped_defs = falseon them, and require full typing on anything new. Move modules out of the exception list as you clean them up. - Set a floor, work up. Start with
warn_return_any = truealone. When that passes, adddisallow_untyped_defs = true. When that passes, flipstrict = true. Each step tightens the ratchet.
Which one you pick depends on how much of the codebase is yours and how much of the team wants types. The one thing I wouldn't do is nothing — mypy at zero strictness still catches real bugs.
Modern type syntax
If your type hints look like this, you're on the old syntax:
from typing import List, Optional, Union, Dict
def get_users(role: str) -> Optional[List[Dict[str, str]]]:
...Python 3.9 (PEP 585) and 3.10 (PEP 604) made most of that unnecessary. On modern Python:
def get_users(role: str) -> list[dict[str, str]] | None:
...list[X], dict[K, V], tuple[X, Y], set[X] — no import needed. X | None instead of Optional[X]. X | Y instead of Union[X, Y]. The typing module still exists for the things you can't do with builtins — TypedDict, Protocol, TypeVar, Final, cast, TYPE_CHECKING. Everything else, use the shorter form.
Typing Django models
Three things go on every model I write.
@final at the class level. If a model isn't meant to be subclassed, say so. mypy stops you from accidentally inheriting from it, and readers know at a glance that this is a terminal class.
A typed manager attribute. The default objects: Manager doesn't know about your custom methods. Annotate it with your actual manager type.
Return types on every method. Not just __str__, every one.
from typing import final
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.db import models
from accounts.managers import UserManager
from core.models import BaseModel
@final
class User(AbstractBaseUser, PermissionsMixin, BaseModel):
email = models.EmailField(unique=True)
first_name = models.CharField(max_length=150, blank=True, default="")
last_name = models.CharField(max_length=150, blank=True, default="")
is_active = models.BooleanField(default=True)
objects: UserManager = UserManager()
USERNAME_FIELD = "email"
REQUIRED_FIELDS: list[str] = []
class Meta:
db_table = "users"
def __str__(self) -> str:
return f"{self.full_name} <{self.email}>"
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}".strip()Once this is in place, User.objects.create_user(...) gets full autocomplete, and mypy will yell if you pass a string to a method that expects an int.
Typing service functions
Services are where types earn their keep. Keyword-only arguments (* in the signature) plus precise input and output types make service calls almost self-documenting.
from decimal import Decimal
from django.db import transaction
from orders.models import Order, LineItem
from orders.selectors.pricing import compute_order_total
@transaction.atomic
def place_order(
*,
user: User,
items: list[LineItem],
shipping_address: str,
) -> Order:
order = Order.objects.create(
user=user,
shipping_address=shipping_address,
status=Order.Status.PENDING,
)
for item in items:
item.order = order
LineItem.objects.bulk_create(items)
order.total = compute_order_total(order=order)
order.save(update_fields=["total"])
return orderThree habits worth naming:
*in every service signature. Positional arguments are error-prone across module boundaries. Keyword-only calls are self-labelling and can't be reordered by accident.- Concrete types on inputs. Don't take
list[dict]when you meanlist[LineItem]. - A named return type, always. Even for functions that return
None.-> Noneis a promise; a missing return type is a shrug.
TypedDict for structured payloads
A lot of Python code takes and returns dicts. Webhook payloads, third-party API responses, inter-service messages. A plain dict[str, Any] is a type checker's white flag. TypedDict is the fix.
from typing import NotRequired, TypedDict
class PaystackWebhookMetadata(TypedDict):
order_id: str
customer_id: str
class PaystackWebhookData(TypedDict):
id: int
reference: str
amount: int
currency: str
status: str
paid_at: NotRequired[str] # missing on failed charges
metadata: PaystackWebhookMetadata
class PaystackWebhook(TypedDict):
event: str
data: PaystackWebhookDataThe webhook handler takes a PaystackWebhook and mypy tells you exactly which fields exist and which are optional. When Paystack changes its response shape (or when your junior misspells a key), you find out at build time.
NotRequired[X] marks a key as optional (present or absent). Required[X] does the reverse inside a class that has total=False. Between them you can model any dict shape precisely.
I use TypedDict for the ingress edges of the system — HTTP webhooks, LLM tool outputs, third-party APIs. Once the data crosses into the domain, it gets promoted to a real object (a model, a service input, a frozen dataclass). Dicts are wire format; dataclasses are internal.
Frozen dataclasses for value objects
For a computed result that has behavior or invariants, a @dataclass(frozen=True) beats a dict every time.
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class ReconciliationResult:
currency: str
external_balance: Decimal
ledger_balance: Decimal
drift: Decimal
ok: bool
def alert_message(self) -> str:
return (
f"[{self.currency}] drift={self.drift} "
f"(external={self.external_balance}, ledger={self.ledger_balance})"
)Frozen means immutable — you can't accidentally mutate the drift on an instance you passed to a Slack notifier. The methods sit right next to the data. And every field has a type, so downstream code knows what it's getting.
The rule I use: if it's data crossing a wire, TypedDict. If it's a value in the domain, frozen dataclass. Django models are the third bucket — persistence-backed entities.
Protocol for structural typing
Sometimes you want "anything shaped like this" without demanding inheritance. TypeScript readers already know this idea — you write an interface, anything that satisfies it fits, no implements keyword. In Python, that's Protocol.
from typing import Protocol
class ObjectStorage(Protocol):
def upload(self, *, key: str, data: bytes, content_type: str) -> str: ...
def signed_url(self, *, key: str, expires_in: int) -> str: ...
def save_receipt(*, storage: ObjectStorage, receipt: bytes) -> str:
key = f"receipts/{uuid7()}.pdf"
storage.upload(key=key, data=receipt, content_type="application/pdf")
return storage.signed_url(key=key, expires_in=3600)Any class with those two methods works — GCSStorage, S3Storage, a InMemoryStorage you wrote for tests. No inheritance, no ABC. Structural typing is under-used in Python and it's the single feature that most feels like TypeScript to me.
Handling QuerySets and related managers
The one place beginners hit a wall is Django's ORM. Two rules that solve most of the pain.
Import QuerySet from django.db.models, not the module docs suggest. It's generic — QuerySet[User].
from django.db.models import QuerySet
def active_users(*, organization: Organization) -> QuerySet[User]:
return User.objects.filter(organization=organization, is_active=True)Related managers are inferred by django-stubs. user.orders.filter(...) types correctly because the plugin looks at your ForeignKey(related_name="orders") and generates the right manager. If it doesn't, either the stubs missed something or the related_name isn't set — check both before reaching for # type: ignore.
Escape hatches
Types are a tool, not a religion. Three escape hatches I use, in order of preference.
assert isinstance(x, Y) narrows the type below the assert without a runtime cost in production if you strip asserts. Good for the top of a service function that received something loosely typed.
cast(Y, x) tells mypy "trust me". Zero runtime effect. Use when you know something the type checker can't prove — usually because a third-party library returned Any.
from typing import cast
user = cast(User, request.user) # request.user is AbstractBaseUser# type: ignore[error-code] silences a specific error on one line. Always with a specific code, never bare. And leave a comment about why:
result = some_dynamic_thing() # type: ignore[assignment] # returns Any from third-party libBare # type: ignore is banned in my configs. If you're going to silence something, you should know what you silenced.
TYPE_CHECKING for circular imports
Type annotations don't run at import time. So types can reference names that would be circular at runtime.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from orders.models import Order # only for the annotation below
def enqueue_invoice(*, order: "Order") -> None:
...The if TYPE_CHECKING block runs only in the type checker, never at runtime. The forward-reference string ("Order") is how you use a type that isn't imported at runtime. This solves the two-hardest circular import problems in Django codebases.
Adopting mypy without stopping the world
If you inherit an untyped codebase, don't try to type it all at once. What works:
- Turn on mypy at low strictness first (
warn_return_any, nothing else). - Add a per-module override that requires
disallow_untyped_defsfor new modules only. - Any new function anywhere requires a return type — enforced in code review, not by mypy.
- When someone touches an old module for a real change, they can pay a small typing tax on the way through. No refactor sprints.
- Every six months, look at the untyped list and see what's obvious to clean up in an afternoon.
The team ratchet matters more than the mypy setting. If people write typed code today because that's what they see in the repo, in 12 months you have a typed codebase. If you demand a big-bang cleanup, you get resentment and skipped hooks.
Part 2 — TypeScript
Most of the ideas above have direct TypeScript equivalents you already use if you're writing TypeScript at all. This section is about the patterns that pay for themselves and aren't obvious.
Types near where they're used
The temptation on a new frontend is a single src/types.ts (or a types/ folder) with every interface in it. I don't do this. Types live next to the code that uses them.
src/services/accounts/
├── api.ts # RTK Query endpoints
├── slice.ts # Redux slice
└── types.ts # User, LoginBody, LoginResponse — accounts-only
Shared types (response envelopes, pagination, error shapes) live at the top of src/services/ as apiTypes.ts. Nothing that's used in one place lives in a global bucket.
API contracts as types
Type your API surface once, at the boundary. Every domain service imports from the same shared types file.
// src/services/apiTypes.ts
export type Paginated<T> = {
count: number;
next: string | null;
previous: string | null;
results: T[];
};
export type ApiError = {
code: string;
message: string;
request_id?: string;
fields?: Record<string, string[]>;
};Now every list endpoint is Paginated<Order> or Paginated<User> and every error is ApiError. Consistency at the type level; no per-endpoint shape drift.
Redux Toolkit typing idioms
Three types RTK asks you to write once, then everything downstream is inferred.
// src/store/store.ts (excerpt)
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;Typed hooks so components don't reach for the untyped useSelector / useDispatch:
// src/store/hooks.ts
import {
useDispatch,
useSelector,
type TypedUseSelectorHook,
} from "react-redux";
import type { AppDispatch, RootState } from "./store";
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;PayloadAction<T> on every slice action:
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
const authSlice = createSlice({
name: "auth",
initialState: { token: null as string | null },
reducers: {
setToken(state, action: PayloadAction<string>) {
state.token = action.payload;
},
},
});That's the whole typing overhead of Redux Toolkit. Six lines total, and every consumer downstream is fully typed.
Prefer unknown over any
any is TypeScript's "give up" button. unknown is "I don't know yet, but I promise to check before I use it." One tells the compiler to stop; the other keeps it engaged.
async function parseWebhook(payload: unknown): Promise<PaystackWebhook> {
if (!isPaystackWebhook(payload)) {
throw new Error("invalid webhook payload");
}
return payload;
}You can't do anything useful with an unknown until you narrow it. That's the point.
Runtime validation with schemas
Types disappear at runtime. Data from over the wire is a lie until you check it. Zod (or Yup) fills the gap — you write a schema, get a validator and a TypeScript type for free.
import { z } from "zod";
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type LoginValues = z.infer<typeof LoginSchema>; // { email: string; password: string }I use Zod for API response validation and Yup for React Hook Form (only because RHF's resolver ergonomics are slightly nicer with Yup — Zod works too). Either way, the schema is the source of truth for both shape and validation.
How they meet
When both sides are typed, the boundary starts working for you.
The DRF response envelope has a Python type. The TypeScript type mirrors it. If the backend changes the shape, either both types update together or CI turns red on one side. The webhook TypedDict on the Django side matches a type on the TypeScript side (or vice versa if the webhook goes to a Node function).
It doesn't have to be automatic. Tools that generate TypeScript types from an OpenAPI schema exist, and I've used them, but honestly the highest-signal typing bug isn't caught by generated types — it's caught by two humans keeping two type files in sync because they know they have to. The tooling helps at scale. The habit does most of the work.
If you're not sold
Try it on one service function. Add a return type and a TypedDict for the input, and run mypy on that one module with strict = true. See what it finds. See what you don't have to think about anymore.
I resisted this for years, then spent a week doing it, and I still resent the years. It's not a language debate anymore for me. It's a habit that costs nothing after the first week and pays for itself the first time it stops a bug at the type-check step. If you've been meaning to try, this weekend is a good time.
- python
- typescript
- django
- software development