Skip to content
Sarj Code Standards

prefer-nominal-id-types

Python boundaries should distinguish swappable identifier roles with nominal types.

Why

Two identifiers with the same primitive or container carrier can be exchanged without a type-checking error. Erasing an established domain ID has the same problem even when a boundary accepts one identifier. Nominal types make those role mistakes visible while leaving unlike carriers alone.

Fix

Use typing.NewType as the low-runtime-cost default and propagate it from the raw edge through the domain boundary. A nominal value object is also valid when runtime validation or behavior is required.

Examples

Before — flagged A single ID retains its established domain type
app/services/files.py
from typing import NewType
FileId = NewType("FileId", str)
def load(file_id: str) -> None: ...
After — preferred The boundary uses the existing nominal ID
app/services/files.py
from typing import NewType
FileId = NewType("FileId", str)
def load(file_id: FileId) -> None: ...
Before — flagged Two domain ID roles share the same primitive carrier
app/services/files.py
def move(file_id: str, parent_folder_id: str) -> None: ...
After — preferred NewType makes the two domain roles distinct
app/services/files.py
from typing import NewType
FileId = NewType("FileId", str)
FolderId = NewType("FolderId", str)
def move(file_id: FileId, parent_folder_id: FolderId) -> None: ...
Before — flagged Abstract contracts declare their own nominal ID boundary
app/contracts.py
from abc import ABC, abstractmethod
class FilePort(ABC):
@abstractmethod
def move(self, file_id: str, folder_id: str) -> None: ...
After — preferred Abstract contracts use distinct domain identifiers
app/contracts.py
from abc import ABC, abstractmethod
from typing import NewType
FileId = NewType("FileId", str)
FolderId = NewType("FolderId", str)
class FilePort(ABC):
@abstractmethod
def move(self, file_id: FileId, folder_id: FolderId) -> None: ...