AI
← All posts

· 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-stubs

Put 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:

  1. New rule for new code. Add [[tool.mypy.overrides]] for existing modules, set disallow_untyped_defs = false on them, and require full typing on anything new. Move modules out of the exception list as you clean them up.
  2. Set a floor, work up. Start with warn_return_any = true alone. When that passes, add disallow_untyped_defs = true. When that passes, flip strict = 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 order

Three 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 mean list[LineItem].
  • A named return type, always. Even for functions that return None. -> None is 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: PaystackWebhookData

The 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 lib

Bare # 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:

  1. Turn on mypy at low strictness first (warn_return_any, nothing else).
  2. Add a per-module override that requires disallow_untyped_defs for new modules only.
  3. Any new function anywhere requires a return type — enforced in code review, not by mypy.
  4. When someone touches an old module for a real change, they can pay a small typing tax on the way through. No refactor sprints.
  5. 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.

Comments