In the past year, as someone who has mostly come from a TypeScript and other strongly typed languages, I’ve joined a project that is developing software heavily utilizing LLMs. As a result, we’ve been using the language of AI developers: Python!
This was the first time I had used Python, and jumping into the codebase was an adjustment, requiring me to pay attention to indentation, introducing async coding patterns into a largely synchronous codebase, and dealing with an overall lack of type annotations in existing code. Over the past year, I’ve discovered tools and patterns that bring the type safety I loved in other programming languages into my Python work. My best friend throughout this type-safe Python journey? Python’s typing package and the static analysis tools and IDE intelligence it makes possible.
Basic Type Annotations
Python’s type hints work similarly to TypeScript’s annotations. You can annotate function parameters, return types, and variable declarations:
def add(a: int, b: int) -> int:
return a + b
Final
Coming from TypeScript, I’m used to declaring constants with const to prevent reassignment. Python’s Final annotation serves a similar purpose—it signals to type checkers that a variable should never be reassigned. While Python won’t prevent reassignment at runtime (it’s not enforced), static type checkers will catch these errors during development.
from typing import Final
MAX_TOOL_CALLS: Final[int] = 3
MAX_TOOL_CALLS = 5 # "MAX_TOOL_CALLS" is declared as Final and cannot be reassigned
Union Types and String Literals
Python’s union types with Literal provide TypeScript-like string literal unions, ideal for defining constrained sets of valid values. The pipe operator (|) works just like TypeScript’s union syntax, and type checkers will verify that only valid literal values are used.
from typing import Literal
PaymentStatus = Literal["pending", "completed", "failed"]
def process_payment(amount: float) -> PaymentStatus | None:
if amount <= 0: return "failed" elif amount > 10000:
return "pending" # Large amounts need approval
else:
return "completed"
status: Final[PaymentStatus] = "new_status" # Type "Literal['new_status']" is not assignable to declared type "PaymentStatus"
Exhaustiveness Checking
One of TypeScript’s killer features is exhaustiveness checking—ensuring you handle every case in a union type. Python can also do this, using the Never type and an assertion function. If you add a new status to your Literal type and forget to handle it, the type checker will flag the assert_never call as an error.
from typing import Literal, Never
PaymentStatus = Literal["pending", "completed", "failed", "new_status"]
def assert_never(value: Never) -> Never:
raise AssertionError(f"Unhandled value: {value}")
def handle_payment(status: PaymentStatus) -> str:
if status == "pending":
return "Processing..."
elif status == "completed":
return "Done!"
elif status == "failed":
return "Error!"
else:
assert_never(status) # Argument of type "Literal['new_status']" cannot be assigned to parameter "value" of type "Never" in function "assert_never"
NamedTuple
The first time I needed to return more than one value from a function, I discovered NamedTuple. It’s a lightweight way to create structured return values with named fields and type hints—much cleaner than returning anonymous tuples or dictionaries. You get autocomplete, type checking, and the ability to destructure, or as Python calls it, unpack, the result, just like in TypeScript.
from typing import NamedTuple
class DivisionResult(NamedTuple):
quotient: int
remainder: int
def divide(a: int, b: int) -> DivisionResult:
return DivisionResult(
quotient=a // b,
remainder=a % b,
)
q, r = divide(
17, 5
) # Type hints provide the correct int types of the unpacked values q: int, r: int
print(f"{q}, {r}") # 3, 2
TypedDict
When you need to describe the shape of a dictionary with specific keys and value types, TypedDict is your friend. It’s similar to TypeScript’s type or interface for object shapes, giving you type checking and autocomplete for dictionary keys. This is especially useful when working with JSON-like data structures.
from typing import TypedDict
class Transaction(TypedDict):
transaction_id: str
amount: float
currency: str
status: str
card_last_four: str
merchant_name: str
transaction = Transaction(
transaction_id="txn_abc123",
amount=49.99,
currency="USD",
status="completed",
card_last_four="3333",
merchant_name="Acme Corp"
)
Protocols
TypeScript developers love structural typing with interface—if an object has the right shape, it works. Python’s Protocol brings this same duck-typing approach with static type checking. You define the structure you need, and any object with matching attributes will type-check correctly, without requiring inheritance.
from typing import Protocol
from decimal import Decimal
from datetime import datetime
class PaymentData(Protocol):
amount: Decimal
currency: str
timestamp: datetime
customer_id: str
def calculate_fee(payment: PaymentData) -> Decimal:
fee_rate = Decimal("0.029") # 2.9%
return payment.amount * fee_rate
Pydantic
While Python’s built-in typing tools provide static type checking, Pydantic adds runtime validation. It automatically validates data types, formats, and constraints when objects are created—catching errors that slip past static analysis. This is invaluable when working with external data sources or API payloads.
from typing import Literal
from pydantic import BaseModel, Field
class CreditCardPayment(BaseModel):
type: Literal["credit_card"]
last4: str = Field(..., min_length=4, max_length=4)
brand: Literal["visa", "mastercard", "amex", "discover"]
class BankAccountPayment(BaseModel):
type: Literal["bank_account"]
account_number: str = Field(..., min_length=4, max_length=17)
routing_number: str = Field(..., min_length=9, max_length=9)
class PayPalPayment(BaseModel):
type: Literal["paypal"]
email: EmailStr
Discriminated Unions
Discriminated unions are one of TypeScript’s most powerful features, and I was thrilled to discover that Pydantic brings them to Python. This has been vital in our app—these discriminated unions are exposed in our OpenAPI spec, and our type generator produces matching TypeScript discriminated unions on the frontend, providing us with type safety across the entire stack.
from typing import Literal
from pydantic import BaseModel, Field
class CreditCardPayment(BaseModel):
type: Literal["credit_card"]
last4: str = Field(..., min_length=4, max_length=4)
brand: Literal["visa", "mastercard", "amex", "discover"]
class BankAccountPayment(BaseModel):
type: Literal["bank_account"]
account_number: str = Field(..., min_length=4, max_length=17)
routing_number: str = Field(..., min_length=9, max_length=9)
class PayPalPayment(BaseModel):
type: Literal["paypal"]
email: EmailStr
PaymentMethod = CreditCardPayment | BankAccountPayment | PayPalPayment
class PaymentRequest(BaseModel):
amount: int
method: PaymentMethod = Field(discriminator="type")
Python’s type system provides developers with familiar patterns that are common in other programming languages. While Python’s runtime remains dynamically typed, the development experience can also use the static analysis that other languages provide. If you’re coming from TypeScript or just want more robust Python code, I’d recommend investing in typing your code. From my experience, modern Python typing capabilities make large-scale Python projects significantly more maintainable.