Type-Safe Python for TypeScript Developers

6 min read Original article ↗

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.