Writing Python Like It's Rust

Writing Python Like It's Rust

April 6, 2025

From https://kobzol.github.io/rust/python/2023/05/20/writing-python-like-its-rust.html

This will not include everything because I don’t use all the stuff.

Dataclasses instead of tuples or dictionaries

Instead of using:

def find_person(...) -> Tuple[str, str, int]:

or dictionary:

def find_person(...) -> Dict[str, Any]:
    ...
    return {
        "name": ...,
        "city": ...,
        "age": ...
    }

Actually it is easier to describe using a dataclass:

@dataclasses.dataclass
class City:
    name: str
    zip_code: int


@dataclasses.dataclass
class Person:
    name: str
    city: City
    age: int


def find_person(...) -> Person:

This will make it easier to understand the output of the function.

Being Able to Use Different Classes in a Function

There are times where a function may accept multiple classes but how do you support that using type hints.

@dataclass
class Header:
  protocol: Protocol
  size: int

@dataclass
class Payload:
  data: str

@dataclass
class Trailer:
  data: str
  checksum: int

Packet = typing.Union[Header, Payload, Trailer]
# or `Packet = Header | Payload | Trailer` since Python 3.10

Packet here defines a new type, which can be one of the three classes we defined.

def handle_is_instance(packet: Packet):
    if isinstance(packet, Header):
        print("header {packet.protocol} {packet.size}")
    elif isinstance(packet, Payload):
        print("payload {packet.data}")
    elif isinstance(packet, Trailer):
        print("trailer {packet.checksum} {packet.data}")
    else:
        assert False

def handle_pattern_matching(packet: Packet):
    match packet:
        case Header(protocol, size): print(f"header {protocol} {size}")
        case Payload(data): print("payload {data}")
        case Trailer(data, checksum): print(f"trailer {checksum} {data}")
        case _: assert False

Using newtypes

In Rust it is common to define data types that do not add any new behavior but server simply to specify the domain and intended usage of some other, otherwise quite generate data type – for example integers.

class Database:
  def get_car_id(self, brand: str) -> int:
  def get_driver_id(self, name: str) -> int:
  def get_ride_info(self, car_id: int, driver_id: int) -> RideInfo:

db = Database()
car_id = db.get_car_id("Mazda")
driver_id = db.get_driver_id("Stig")
info = db.get_ride_info(driver_id, car_id)

The error is in get_ride_info arguments are swapped. This won’t cause a type error since both are suppose to be integers.

from typing import NewType

# Define a new type called "CarId", which is internally an `int`
CarId = NewType("CarId", int)
# Ditto for "DriverId"
DriverId = NewType("DriverId", int)

class Database:
  def get_car_id(self, brand: str) -> CarId:
  def get_driver_id(self, name: str) -> DriverId:
  def get_ride_info(self, car_id: CarId, driver_id: DriverId) -> RideInfo:


db = Database()
car_id = db.get_car_id("Mazda")
driver_id = db.get_driver_id("Stig")
# Type error here -> DriverId used instead of CarId and vice-versa
info = db.get_ride_info(<error>driver_id</error>, <error>car_id</error>)

This is a very simple pattern that can help catch errors that are otherwise hard to spot. It is especially useful e.g. if you’re dealing with a lot of different kinds of IDs (CarId vs DriverId) or with some metrics (Speed vs Length vs Temperature etc.) that should not be mixed together.