Using Types Effectively

Author

Edvard Majakari

Published

April 9, 2025

Keywords

typing, programming, python, mypy, type systems

In this article, we’ll explore practical ways to leverage static typing. Starting from basic union types to more refined concepts like Algebraic Data Types (ADTs) and finally Phantom types. To keep things a bit more interesting, we’ll apply these ideas to a fantasy-themed dungeon crawler RPG setting, illustrating how thoughtful domain modeling can neatly sidestep entire classes of bugs.

The core idea here is straightforward yet powerful: making invalid states unrepresentable by just using type system. This clever strategy helps us catch numerous errors at compile-time (or, in Python’s case, during type analysis), significantly reducing the likelihood of bugs that might otherwise inconvenience end users.

While this principle shines brightest in compiled languages, excellent work accomplished by mypy developers can bring us most of the same benefits. Rather than just preventing trivial mistakes (like letters sneaking into postal codes), a well-structured type system can express complex, logical transitions without adding any runtime overhead. Done right, this approach doesn’t just prevent errors; it also makes code easier to read and maintain. It is also worth emphasizing that the type system described here is implemented by mypy, not Python itself.

Additionally, it can reduce or entirely eliminate certain forms of errors. If a condition simply cannot occur according to your types, there’s no need to test for it explicitly.

Note

Examples assume Python 3.10+ to leverage the match statement and ML-style syntax for union types (|).

Acknowledgements

I really wish to thank Marko Saaresto for all good suggestions, ideas and support! I also need to express my gratitude for concise, but quite eye-opening explanations and corrections by our resident Type System Sensei Lauri Alanko.

Enum-erate and conquer

Core idea in typing is to constrain set of values in such a manner that (ideally) only legal values can be assigned. Dictionaries are very common datastructures in Python and often very convenient, but become easily problematic with more large codebases. Main issues often are related to strings used as keys with dictionaries.

To get started with our dungeon crawler game, assume our hero needs to be able to attack various monsters lurking in the darkness, we could come up with something like

def attack_enemy(weapon: str, attack_type: str) -> int:
    return {
        "sword": {
            "quick": 5,
            "strong": 10,
            "special": 15,
        },
        "bow": {"quick": 3, "strong": 7, "special": 12},
        "staff": {
            "quick": 2,
            "strong": 4,
            "special": 20,
        },
    }[weapon][attack_type]
Note

I tend to avoid creating variables for things which are used only once; so instead of creating the dictionary and then returning an element from it, I just return the element directly. This saves me the trouble of inventing good names and I also know that variable is very likely used more than once (with the exception of constants and loop variables) – unless using a variable would simplify it a lot; lengthy isn’t the same as complex

Shown approach presents some regrettable limitations:

  • No compile-time validation
  • Typos in string literals won’t be caught until runtime
  • No IDE autocompletion support

Worst of all, it is not uncommon to see such dictionaries passed around multiple modules, some keys created dynamically by manipulating strings etc. In such cases it is often practically impossible to see what all values are possible, or even what is the structure of valid value (assuming dictionary contains nested data)

In particular, the type checker will not warn about attack_enemy("scimitar", "special") until runtime. One option to tackle the issue would be to change return value to int | None and use non-strict dictionary access, but then we would taint all calls and introduce potential None error, forcing callers to (often repeatedly) check for None, thus making code unnecessarily convoluted, or risk throwing KeyError.

with Literal

from typing import Literal


def attack_enemy(
    weapon: Literal["sword", "bow", "staff"],
    attack_type: Literal["quick", "strong", "special"],
) -> int:
    return {
        "sword": {
            "quick": 5,
            "strong": 10,
            "special": 15,
        },
        "bow": {"quick": 3, "strong": 7, "special": 12},
        "staff": {
            "quick": 2,
            "strong": 4,
            "special": 20,
        },
    }[weapon][attack_type]

Using Literal we define precisely which values are permissible, gaining compile-time validation via type checker. It enables IDE autocompletion, and documents valid values directly within the type system. The code becomes more maintainable, as addition of new values forces updating the type definition.

with Enum

from enum import Enum


class WeaponType(Enum):
    SWORD = "sword"
    BOW = "bow"
    STAFF = "staff"


class AttackType(Enum):
    QUICK = "quick"
    STRONG = "strong"
    SPECIAL = "special"


def attack_enemy(weapon: WeaponType, attack_type: AttackType) -> int | None:
    return {
        WeaponType.SWORD: {
            AttackType.QUICK: 5,
            AttackType.STRONG: 10,
            AttackType.SPECIAL: 15,
        },
        WeaponType.BOW: {
            AttackType.QUICK: 3,
            AttackType.STRONG: 7,
            AttackType.SPECIAL: 12,
        },
        WeaponType.STAFF: {
            AttackType.QUICK: 2,
            AttackType.STRONG: 4,
            AttackType.SPECIAL: 20,
        },
    }[weapon][attack_type]

This final version employing enums offers additional advantages. Values are grouped in more prominent fashion with an explicit namespace. Enum values may be modified without disrupting serialized data. Enums are self-documenting, may easily include docstrings (as values), while still providing full IDE autocompletion.

Choose your own ADTventure

We already saw how enums can be used to constrain allowed values to prevent typos or other coarse errors. In general, what we often want to do though is to create Algebraic Data Types (ADTs) to model idea of varying options without making code more fragile or convoluted with unnecessary checks.

Let’s define some terms first: while unions and sum types are often used interchangeably, they have a crucial difference. A union type specifies a value that can be exactly one of several specified types, but without explicit tags distinguishing the variants. A sum type explicitly tags each variant, making it impossible to confuse one variant with another (often called tagged union for that very reason).

In other words, a sum type is a union type with a constraint that only one variant can be held at a time. Sum types are also known as tagged unions, discriminated unions, or algebraic data types (ADTs).

To show how useful ADTs are, let’s start simple problem of being able to use_item() during our adventures, starting with

from typing import TypedDict


class Item(TypedDict):
    type: str


def use_item(item: Item) -> str:
    if item["type"] == "potion":
        return f"Grabbed potion with color {item.get('color', 'unknown')}"
    elif item["type"] == "scroll":
        return f"Grabbed scroll with spell {item.get('spell_name', 'unknown')}"
    elif item["type"] == "weapon":
        return f"Grabbed weapon named {item.get('name', 'unknown')}"
    else:
        raise ValueError(f"Unknown item type: {item}")

While implementation might look safe due to use of TypedDict with type: str key, it is not very safe. While much better than having just dict[str, ...] we could have similar other dicts with type key, and we would be able to pass that to use_item() as long as the dictionary would have the same structure as Item.

Note

While Python’s | syntax creates a union, we are using it here to model a sum type, where each variant is a distinct, disjoint case of an overarching concept. In some languages (e.g., Haskell, Rust, F#, OCaml, Elm..), sum types are first-class language features with explicit syntax. Python lacks direct support, but we can emulate sum types with combined use of unions and dataclasses.

If there was a God, He would probably be hacking new extensions for Glasgow Haskell Compiler (GHC). Just sayin’

Let’s examine a more refined implementation required to deal with data type with varying (sub-type) constructors, demonstrating sum types:

from dataclasses import dataclass


@dataclass
class Potion:
    name: str
    healing: int


@dataclass
class Scroll:
    name: str
    spell: str


@dataclass
class Weapon:
    name: str
    damage: int


Item = Potion | Scroll | Weapon


def use_item(item: Item) -> str:
    match item:
        case Potion(name=name, healing=healing):
            return f"Grabbed healing potion {name}, heals {healing} HP"
        case Scroll(name=name, spell=spell):
            return f"Grabbed scroll of {spell} ({name})"
        case Weapon(name=name, damage=damage):
            return f"Grabbed {name}, dealing {damage} damage)"

This implementation offers several advantages over our previous dictionary-based approach. First, the type checker provides static verification, ensuring that use_item() only accepts our defined classes. The code becomes more readable as each type’s properties are explicitly defined in the class structure. When we need to extend our system with new variants (such as adding an Armor class), the type checker would immediately alert us if we haven’t handled the new case. Perhaps most elegantly, the match statement works harmoniously with our sum type, providing a clean and exhaustive way to handle all possible variants without nested conditionals.

The last point is worth emphasizing: match plays particularly well here because there is no need for catch-all case _ branch. So unless the condition is trivial, we would prefer match over if. The code is also more clear to read, as each case is necessarily compared against the same, single expression.

With if statement, each elif-branch could have completely unrelated, different condition. In some cases it is not clear unless each branch is read very carefully; match can make this obvious

We would also get much better autocompletion for most IDEs/editors by using dataclasses over (even) typed dictionaries.

A NewType of identity

NewType is a powerful feature for distinguishing semantically different values that share the same base type. While similar to Literals in that it constrains values, it works at the type level rather than the value level.

In our dungeon crawler, we might have different types of IDs that are all integers at runtime but represent different concepts in our domain. For example, a character’s health points and their level are both integers, but they represent fundamentally different things. Let’s see how we can use NewType to prevent mixing these up:

from typing import NewType

MonsterID = NewType("MonsterID", int)
PlayerID = NewType("PlayerID", int)


def damage_monster(monster_id: MonsterID, amount: int) -> int:
    print(f"Damaging monster {monster_id} by {amount}")
    return amount


def heal_player(player_id: PlayerID, amount: int) -> None:
    print(f"Healing player {player_id} by {amount}")


monster_id = MonsterID(42)
player_id = PlayerID(1)

damage_monster(monster_id, 10)
heal_player(player_id, 5)

damage_monster(player_id, 10)  # type: ignore[arg-type]
Note

I run linters and mypy on all code samples before publishing, and # type: ignore comment ensures code works as intended. If mypy would not trigger an error on such line, ruff would complain about unnecessary type: ignore, thus ensuring that types really prevent invalid code.

This approach provides more type safety, as the type checker will catch any attempts to mix up different types of IDs with zero runtime overhead, since NewType is erased at runtime. It also makes code more self-documenting, as the type system prominently indicates what kind of ID is expected.

Generic fantasy inventory system

Generics let you define reusable data structures that preserve type information. They build upon the concept of union types by allowing us to work with collections of any type while maintaining type safety.

In our dungeon crawler, we might want to create a type-safe inventory system that can hold different types of items while ensuring we can’t mix incompatible items. Let’s see how generics can help:

from dataclasses import dataclass
from typing import Generic, TypeVar, List

T = TypeVar("T")


@dataclass
class SpellBook:
    name: str
    arcane_level: int


@dataclass
class Item:
    name: str
    value: int


class Inventory(Generic[T]):
    def __init__(self) -> None:
        self.items: List[T] = []

    def add(self, item: T) -> None:
        self.items.append(item)

    def get_all(self) -> List[T]:
        return list(self.items)


books = Inventory[SpellBook]()
books.add(SpellBook("Fireball", 10))

items = Inventory[Item]()
items.add(Item("Health Potion", 50))

items.add(SpellBook("Call Lightning", 8))  # type: ignore[arg-type]

This example demonstrates several key benefits of generics. First, the type system knows exactly what kind of items are in each inventory, preserving type information throughout your code. Second, the type checker ensures we can’t mix incompatible items, catching errors at compile time rather than runtime. Third, the same inventory code works with any type of item, making it highly reusable across your codebase. Finally, you get full IDE support with autocompletion for item-specific methods, improving developer productivity and reducing errors.

As a common example, generics allow us to write flexible functions such as filter, fold (reduce in Python) and map. These are all parametrically polymorphic, type-preserving functions.

A wild functor appears!

Related to generics, there exists a concept so powerful I can’t help not mentioning it. It is called a functor, which generalizes composition of “plain” functions over values which can be “mapped over”. Functors are rather ubiquitous structures in functional programming languages

Every FP fanboy rants about Monads. But Functors are even more useful, and more generic! Not to mention that every monad is also a functor.

In programming, a functor F is a type constructor (like List, Result, Tree…), and a function f which operates on normal (unlifted) values of type T, returning new function which works on “lifted” values F(T). Functors are incredibly powerful constructs when a language has built-in support for those, providing developer

  • Composition without boilerplate allowing operations on collections without manually writing loops or comprehensions
  • Type-safe transformations with errors caught at compile time
  • Consistent interfaces: uniform API to work with arbitrarily different container types
  • Code reusability as functions written for simple types can be automatically “lifted” to work on containers of those types

So what is it then? Are functors just clever type constructors, or merely functions conforming to an interface? Neither. A functor is an example of a type class1 – concept roughly comparable to interfaces in many mainstream languages, but with a crucial distinction; while a type implementing an interface must explicitly declare that relationship, type classes are defined independently of the types they apply to. A type conforms to given type class when the developer provides appropriate declaration, typically by creating an instance of the type class or supplying an implementation that satisfies requirements of said class. This makes the relationship entirely decoupled from the original type definition. In Rust and Scala, very similar concept appears as traits.

While functors would not work well with type systems available for Python, several compiled languages provide these powerful abstractions either natively (Haskell, OCaml, ReasonML) or through well-integrated libraries (like Cats in Scala or Arrow in Kotlin).

This would require higher-kind types (HKTs in type-theory jargon), because functors map types to another type

You shall not pass (unless valid)

When you must handle data of uncertain shape (e.g., a JSON payload), type guards let you refine Any or union types using runtime checks.

In our dungeon crawler, we might receive item data from a network API or configuration file. The data structure is known but not guaranteed at compile time. Let’s see how type guards can help us safely handle this:

from typing import Any, TypeGuard, TypedDict


class MonsterData(TypedDict):
    name: str
    difficulty: int


def is_monster_data(data: Any) -> TypeGuard[MonsterData]:
    match data:
        case {"name": str(), "difficulty": int(), **_rest}:
            return True
        case _:
            return False
  • line 4: TypedDict makes the expected structure explicit instead of using raw dict[str, ...]
  • line 11: This ensures “name” and “difficulty” exist and have the correct types, but does NOT check whether all keys are strings
  • line 12: Type signature declares return value to be TypeGuard[MonsterData], even though it is obviously bool. This is what mypy documentation calls “smart booleans”: If result is True, the type checker will assume data : MonsterData

Type guards provide some important benefits. They allow us to narrow types by helping the type checker to understand the specific type after validation has occurred, thus maintaining type safety by preserving type information throughout your program. While that check happens at runtime, it still beats isinstance checks.

You’ll find type guards particularly valuable in several, common scenarios. When working with external APIs or data sources, they help in ensuring the data conforms to your expectations. When parsing configuration files or user input, they validate the structure before you use it.

In a state of denial

Beyond data shapes, we can also address logical states. This builds upon the ideas from Union Types and Type Guards to create a more expressive type to represent state machine.

Simple approach

Let’s consider a scenario with magical artifacts which can be in different states: unholy, normal or blessed. In addition, casting certain spell can make such item radiant emanating aura of healing. We might start with something like

Obviously unholy items cannot produce healing effects!
from dataclasses import dataclass, replace
from enum import Enum, auto
import time


class State(Enum):
    UNHOLY = auto()
    NORMAL = auto()
    BLESSED = auto()


@dataclass(frozen=True)
class Artifact:
    name: str
    power: int
    state: State
    healing_power: int = 0  # Only used when state is RADIANT


def bless_artifact(artifact: Artifact) -> Artifact:
    print(f"Playing blessing animation for {artifact.name}...")
    time.sleep(1)  # Simulate expensive animation
    print("...")
    print("Blessing animation complete!")

    if artifact.state not in (State.NORMAL, State.UNHOLY):
        print("Can only bless normal or unholy artifacts")
        return artifact

    # Blessed artifacts get a healing bonus when made radiant
    return replace(artifact, healing_power=artifact.power * 2)


def make_radiant(artifact: Artifact) -> Artifact:
    if artifact.state in (State.BLESSED, State.NORMAL):
        return replace(artifact, healing_power=artifact.power)
    else:
        print("Only normal or Blessed artifacts can be made radiant")
        return artifact


print("First shalt thou take out the Holy Pin.")
unholy_grenade = Artifact(
    name="Holy Hand Grenade of Antioch",
    power=1000,
    state=State.UNHOLY,
)

print("Then shalt thou count to three, no more, no less.")
normal_grenade = bless_artifact(unholy_grenade)

print(
    "Four shalt thou not count, neither count thou two, "
    "excepting that thou then proceed to three. Five is right out."
)
blessed_grenade = bless_artifact(normal_grenade)

print(
    "Once the number three, being the third number, be reached, then lobbest thou "
    "thy Holy Hand Grenade of Antioch towards thy foe, who, being naughty in My sight, shall snuff it."
)

radiant_grenade = make_radiant(blessed_grenade)

bless_artifact(radiant_grenade)  # but this was already blessed..

Unnecessary extra call to bless would not crash the system, but surely you can imagine cases where calling a function accidentally too often might be extremely harmful. In this case it only executes ‘expensive’ animation twice. But we have more severe issues:

Which is why idempotent functions are often desirable
  • State transitions are validated at runtime, meaning errors only surface when the code is executed
  • The single Artifact class with a state field makes it possible to create invalid combinations (e.g., a blessed artifact with healing power)
  • Type information is lost, making it harder for the type checker to catch errors
  • The code requires manual state checking and error handling

Improved approach with type-based states

from dataclasses import dataclass
from typing import overload, TypeVar
import time


@dataclass(frozen=True, kw_only=True)
class BaseArtifact:
    name: str
    power: int


@dataclass(frozen=True)
class UnholyArtifact(BaseArtifact): ...


@dataclass(frozen=True)
class NormalArtifact(BaseArtifact): ...


@dataclass(frozen=True)
class BlessedArtifact(BaseArtifact): ...


@dataclass(frozen=True)
class RadiantArtifact(BaseArtifact):
    healing_power: int


@dataclass(frozen=True)
class RadiantBlessedArtifact(BaseArtifact):
    healing_power: int


T = TypeVar("T", UnholyArtifact, NormalArtifact, RadiantArtifact)


@overload
def bless_artifact(artifact: UnholyArtifact) -> NormalArtifact: ...
@overload
def bless_artifact(artifact: NormalArtifact) -> BlessedArtifact: ...
@overload
def bless_artifact(artifact: RadiantArtifact) -> RadiantBlessedArtifact: ...


def bless_artifact(
    artifact: T,
) -> NormalArtifact | BlessedArtifact | RadiantBlessedArtifact:
    print(f"Playing blessing animation for {artifact.name}...")
    time.sleep(1)  # Simulate expensive animation
    print("... Blessing animation complete!")

    if isinstance(artifact, UnholyArtifact):
        return NormalArtifact(name=artifact.name, power=artifact.power)
    elif isinstance(artifact, NormalArtifact):
        return BlessedArtifact(name=artifact.name, power=artifact.power)
    elif isinstance(artifact, RadiantArtifact):
        return RadiantBlessedArtifact(
            name=artifact.name,
            power=artifact.power,
            healing_power=artifact.healing_power,
        )
    else:
        raise ValueError("Invalid artifact for blessing")


U = TypeVar("U", NormalArtifact, BlessedArtifact)


@overload
def make_radiant(artifact: NormalArtifact) -> RadiantArtifact: ...
@overload
def make_radiant(artifact: BlessedArtifact) -> RadiantBlessedArtifact: ...


def make_radiant(artifact: U) -> RadiantArtifact | RadiantBlessedArtifact:
    match artifact:
        case NormalArtifact():
            return RadiantArtifact(
                name=artifact.name, power=artifact.power, healing_power=artifact.power
            )
        case BlessedArtifact():
            return RadiantBlessedArtifact(
                name=artifact.name,
                power=artifact.power,
                healing_power=artifact.power * 2,
            )


unholy_grenade = UnholyArtifact(name="(Not so) Holy Hand Grenade of Antioch", power=1024)

# must fail to type-check
make_radiant(unholy_grenade)  # type: ignore

normal_grenade = bless_artifact(unholy_grenade)
blessed_grenade = bless_artifact(normal_grenade)

make_radiant(normal_grenade)
make_radiant(blessed_grenade)

# must fail to type-check
bless_artifact(blessed_grenade)  # type: ignore

Now we ensure that artifact transformations follows strict rules by taking better advantage of types. The overloaded bless_artifact() function demonstrates how we can use the type system to enforce different behaviors based on input type.

In particular, now we have encoded valid state transitions dictated by domain logic, by just using the type system.

Now the type system provides strong guarantees about our artifact system, ensuring that only valid state transitions are possible, eg. by preventing attempts to bless already blessed artifacts. Each artifact state has its own specific properties and behaviors clearly defined by its class structure. Most importantly, invalid combinations of states simply cannot exist in our program.

There’s one big issue though: at worst, cardinality of types we need to represent all possible states is a Cartesian product of all the types we’ve declared. Creating dataclasses for each combination would quickly become unwieldy, awkward even. Type signatures are quite convoluted otherwise as well.

Ghosts in the type machine

Phantom types are most useful when we need generic operations that apply to a family of related types, allowing us to create even more sophisticated type-level constraints. While technically idea is quite simple, it is clever enough to elaborate on that a bit.

Let’s look at a simple example first, conveying the key idea. Phantom type is literally a type which appears only in the type signature, ie. there is no matching instance of that type present anywhere. Back to our hero, assume we’d want to ensure any spell must be checked to be safe via some function, and we would want to achieve this with types. We could of course create extra types for each possible combination, but as seen before, this becomes awkward very quickly. Phantom types allow us to “tag” any existing types with meaningul symbols:

from typing import TypeVar, Generic, Callable
from dataclasses import dataclass
import re

T = TypeVar("T")
Marker = TypeVar("Marker")


@dataclass
class Phantom(Generic[T, Marker]):
    v: T


# fmt: off
class Safe: ...  # actual phantom type
class SafetyCheckError(Exception): ...
# fmt: on


def validate(value: T, pred: Callable[[T], bool]) -> Phantom[T, Safe]:
    if pred(value):
        return Phantom(value)

    raise SafetyCheckError(f"Validation failed for value: {value!r}")


def read_scroll(scroll: Phantom[str, Safe]) -> None:
    print(f"Reading scroll {scroll.v}")


def main() -> None:
    unchecked_scroll = "enchant weapon'; DROP TABLE INVENTORY; --"

    try:
        safe_scroll = validate(
            unchecked_scroll.strip(), lambda s: bool(re.match(r"^[a-z ]$", s))
        )
        read_scroll(safe_scroll)  # ok
    except SafetyCheckError as e:
        print(f"Error: {e}")

    # fails to typecheck as desired
    read_scroll(unchecked_scroll)  # type: ignore[arg-type]
Note

For more serious use of phantom types in Python, you might want to check out https://github.com/antonagestam/phantom-types/

Pay extra attention to our Phantom class wrapper. It is extremely simple dataclass with only sigle field v, containing the actual value. But there is also this type variable Marker which doesn’t appear anywhere in the class definition. This is exactly the idea! In read_scroll() function it allows us to convince the type checker that if the function doesn’t throw, then our scroll is guaranteed to be safe.

Revisiting our slightly less trivial artifact system, the explosion of types needed to handle all potential combinations is the main issue: we needed separate types (classes!) for almost every combination. Phantom types are very suitable here, as they allow us to refine the type focusing on one particular aspect only – denoted by the tag/marker we use:

from dataclasses import dataclass, replace
from typing import NewType, TypeVar, Generic, overload, Any
import time

Unholy = NewType("Unholy", object)
Normal = NewType("Normal", object)
Blessed = NewType("Blessed", object)

T = TypeVar("T", Unholy, Normal, Blessed)


@dataclass(frozen=True)
class BaseArtifact(Generic[T]):
    name: str
    power: int


@dataclass(frozen=True)
class Artifact(BaseArtifact[T]): ...


@dataclass(frozen=True)
class RadiantArtifact(BaseArtifact[T]):
    healing_power: int = 0


ArtifactType = Artifact[T] | RadiantArtifact[T]


@overload
def bless_artifact(artifact: Artifact[Unholy]) -> Artifact[Normal]: ...
@overload
def bless_artifact(artifact: Artifact[Normal]) -> Artifact[Blessed]: ...
@overload
def bless_artifact(
    artifact: RadiantArtifact[Normal],
) -> RadiantArtifact[Blessed]: ...


def bless_artifact(artifact: ArtifactType[T]) -> ArtifactType:  # type: ignore[type-arg]
    print(f"Playing blessing animation for {artifact.name}...")
    time.sleep(1)
    print("...")
    print("Blessing animation complete!")

    return replace(artifact)


@overload
def make_radiant(artifact: Artifact[Normal]) -> RadiantArtifact[Normal]: ...


@overload
def make_radiant(artifact: Artifact[Blessed]) -> RadiantArtifact[Blessed]: ...


def make_radiant(artifact: Any) -> Any:
    return RadiantArtifact(
        name=artifact.name, power=artifact.power, healing_power=artifact.power
    )


@overload
def use_artifact(artifact: Artifact[Unholy]) -> None: ...
@overload
def use_artifact(artifact: Artifact[Normal]) -> None: ...
@overload
def use_artifact(artifact: Artifact[Blessed]) -> None: ...
@overload
def use_artifact(artifact: RadiantArtifact[Normal]) -> None: ...
@overload
def use_artifact(artifact: RadiantArtifact[Blessed]) -> None: ...


def use_artifact(artifact: ArtifactType[Any]) -> None:
    match artifact:
        case RadiantArtifact(name=name, power=power, healing_power=healing):
            print(f"Using radiant artifact {name=} (power={power}, healing={healing})")
        case Artifact(name=name, power=power):
            print(f"Using standard artifact {name=} (power={power})")


unholy_grenade = Artifact[Unholy](  # for some reason it was unholy when we found it..
    name="(Not so) Holy Hand Grenade of Antioch",
    power=1024,
)

normal_grenade = bless_artifact(unholy_grenade)
blessed_grenade = bless_artifact(normal_grenade)

# must fail to type check
blessed_grenade = bless_artifact(blessed_grenade)  # type: ignore

radiant_normal = make_radiant(normal_grenade)
radiant_blessed = make_radiant(blessed_grenade)

# must fail to type check
make_radiant(unholy_grenade)  # type: ignore

Phantom types provide significant benefits over the previous implementation. By utilizing single Artifact class with type parameters, we avoid the combinatorial explosion of classes that would otherwise be necessary to represent all possible states. The phantom types Unholy, Normal and Blessed allow us to maintain strong type safety while allowing other properties to remain independent and composable. We can freely combine different states without creating dedicated classes for each combination! Despite this flexibility, the type system continues to enforce valid state transitions through carefully defined overloaded functions. Perhaps most importantly, phantom types introduce zero runtime overhead since they exist purely at the type-checking level and are erased during compilation.

Note

Using --strict with mypy is probably necessary for any working phantom type implementation. Not that we would not recommend using that in any case for any mission-critical software.

Venturing further

There are many more advanced concepts in type systems that we haven’t covered here. For example:

  • Generalized Algebraic Datatypes: GADTs extend ADTs, allowing constructors’ result types to depend on input argument types, enabling more precise type-level constraints and stronger compile-time guarantees
  • Dependent Types: Types that may depend on values
  • Type-Level Programming: Programming at the level of types rather than values

Common pitfalls

After toying with some typing tricks, let’s quickly touch on some common unfortunate practises many Python developers tend to make even when working with critical codebases:

  • Overusing Any and vague types. While liberally sprinkling code with Any might silence type-checker warnings, it also defeats the very purpose of having type annotations. Being overly permissive easily leads to subtle bugs lurking undetected.

  • Neglecting strict mode. Many developers miss out by not enabling stricter settings in their type checker, like mypy’s --strict. This leniency leaves unnecessary room for error.

  • Abuse of isinstance While often quick way to ensure something is called for only appropriate types, usually such run-time checks could be turned into compile- (or build-) time checks

  • Validating the same thing multiple times. Especially common with Optional values, there are many ways to avoid this, some of which were shown in this article.

The core objective of effective typing isn’t just error detection, but clarity and maintainability. Well-designed types can make the code easier to understand, reducing cognitive overhead. However, it may be worth noting that poorly designed or overly complex annotations can have the opposite effect. If your annotations start obscuring your logic rather than clarifying it, consider simplifying your approach.

Conclusion

Using types effectively can greatly enhance the robustness and maintainability of your code by enabling you to catch errors at compile time2

We began with the simple idea of replacing arbitrary dictionaries with fixed data structures, strategy that not only guards against coarse errors, but also improves readability and makes large codebases easier to extend. Ultimately, we demonstrated how mypy can model valid state transitions by emulating sum types with unions and dataclasses, and phantom types by combining sum types with NewType and Generics, all without making the implementation impractically convoluted. While some of these constructs are more ergonomic in statically compiled languages, mypy is powerful enough to provide Python developers with a rich toolkit for achieving much stronger type safety.

To summarize, consider using

  • enums for constrained values
  • union types with dataclasses for basic adts* union types allow you to model multiple possible type
  • newtype for domain-specific types with common concrete type
  • generics for type-safe collections
  • type guards for runtime validation
  • phantom types for expressing multiple, independent extra constraints

And finally, likely the most powerful idea: eliminate invalid states by expressing valid state transitions using the type system.

Further reading

For more information on type systems and their applications, consider the following resources:

Harper, Robert. 2009. “Type Systems in Programming Languages.” 2009. https://people.mpi-sws.org/~dreyer/ats/papers/harper-tspl.pdf.
King, Alexis. 2019. “Parse, Don’t Validate.” 2019. https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/.
Minsky, Yaron. 2017. “Caml Trading: Experiences in Functional Programming on Wall Street.” The Monad Reader, Issue 7. 2017. https://wiki.haskell.org/wikiupload/0/03/TMR-Issue7.pdf.
Noonan, Matt. 2020. “Ghosts of Departed Proofs.” 2020. https://kataskeue.com/gdp.pdf.
Pierce, Benjamin C. 2002. Types and Programming Languages. MIT Press.
Wlaschin, Scott. 2020. “Designing with Types: Making Illegal States Unrepresentable.” 2020. https://fsharpforfunandprofit.com/posts/designing-with-types-making-illegal-states-unrepresentable/.

APPENDIX A

You can download all code examples used here

Footnotes

  1. not to confuse with classes in OOP↩︎

  2. -> here: when type-checking, but I’m sure we get that already↩︎