Create your own
Lesson illustration

Creating an Immutable Value Type with Invariants in `__new__`

Good to continue from the previous lesson. We saw that Python’s syntax and built-ins dispatch through type-level special methods rather than ordinary per-instance attribute lookup. This lesson moves earlier in the lifecycle: before an object can participate in a protocol, it must be constructed correctly.

For ordinary mutable objects, __init__ establishes state after allocation. For subclasses of immutable built-ins such as int, str, and tuple, the underlying value is fixed during creation. That makes __new__ the correct place to validate, normalize, and create the value.

By the end of this lesson, you will be able to implement an immutable domain value type whose invalid states cannot be constructed through its public constructor.


Creation comes before initialization

When you write:

item = SomeClass(argument)

you are calling the class object. For ordinary classes, Python’s default metaclass coordinates two distinct phases:

  1. __new__ creates and returns an instance.
  2. __init__ receives that already-created instance and may configure its mutable attributes.

The class call is ultimately handled by the metaclass’s __call__ method. Its usual behavior is conceptually similar to this:

class type:
    def __call__(cls, *args, **kwargs):
        instance = cls.__new__(cls, *args, **kwargs)

        if isinstance(instance, cls):
            cls.__init__(instance, *args, **kwargs)

        return instance

This is a conceptual model, not Python’s literal implementation, but it gives the right timing: __new__ decides what object exists; __init__ configures an object that already exists.

Calling a class enters the metaclass’s `__call__` method, which invokes the class’s `__new__` method to create an instance and then its `__init__` method to initialize that instance when appropriate. For immutable built-in subclasses, the immutable payload must be supplied during `__new__`.

For a mutable class, this division is natural:

class JobRequest:
    def __init__(self, job_type: str, payload: dict[str, object]) -> None:
        self.job_type = job_type
        self.payload = payload

object.__new__ has already allocated an empty JobRequest by the time __init__ runs. Assigning self.job_type and self.payload is ordinary mutable state setup.

An int is fundamentally different. Its numeric value is not an empty attribute waiting to be assigned. An integer object representing 4 is created as that value. There is no supported operation in __init__ that changes it into an integer object representing 0.

"__new__" is awesome in Python

Watch “new is awesome in Python” by Indently for a compact demonstration of why an immutable int subclass must validate and create its value in __new__.

Watch the int example. Focus on the ordering: the range check occurs before super().__new__ constructs the immutable integer payload, and the superclass constructor receives the validated value.


A domain value type: bounded retry count

A job-processing system should not use an unconstrained int to represent every numeric concept. A retry count, for example, has a domain rule:

We can represent that rule directly in a type. The constructor becomes the boundary at which raw input becomes a trustworthy domain value.

from typing import Self


class RetryCount(int):
    """An immutable count of retries permitted for a job."""

    __slots__ = ()

    MIN_VALUE = 0
    MAX_VALUE = 8

    def __new__(cls, value: int) -> Self:
        if isinstance(value, bool) or not isinstance(value, int):
            raise TypeError(
                f"RetryCount requires an int, got {type(value).__name__}"
            )

        if not cls.MIN_VALUE <= value <= cls.MAX_VALUE:
            raise ValueError(
                f"RetryCount must be between "
                f"{cls.MIN_VALUE} and {cls.MAX_VALUE}, got {value}"
            )

        return super().__new__(cls, value)

Use it like an integer where that is semantically appropriate:

retries = RetryCount(3)

print(retries)                 # 3
print(type(retries).__name__)  # RetryCount
print(retries + 1)             # 4
print(int(retries))            # 3

But construction rejects values outside the domain:

RetryCount(-1)
# ValueError: RetryCount must be between 0 and 8, got -1

RetryCount(9)
# ValueError: RetryCount must be between 0 and 8, got 9

RetryCount(True)
# TypeError: RetryCount requires an int, got bool

The return statement is the essential part:

return super().__new__(cls, value)

Because RetryCount inherits from int, super() resolves to int. Calling int.__new__(cls, value) creates an object whose type is RetryCount and whose immutable numeric payload is value.

The argument cls matters. It allows the method to construct the appropriate class, including a legitimate subclass of RetryCount, rather than always constructing a base int.

Why reject bool explicitly?

Python defines bool as a subclass of int:

isinstance(True, int)  # True

Therefore, a check using only isinstance(value, int) would accept True as the retry count 1 and False as 0. That may be technically legal Python, but it is usually an input-quality bug in a domain type. The explicit isinstance(value, bool) check makes the constructor’s contract unambiguous.

Whether to accept values such as numeric strings is a policy decision. Here, RetryCount is a domain value type, not a parser: its caller must parse untrusted JSON, query parameters, or environment variables before invoking the constructor. This keeps failure causes clear and avoids surprising coercions such as accepting "03" in one path but not another.


Why __init__ cannot establish an immutable payload

Here is an incorrect implementation:

class BrokenRetryCount(int):
    def __init__(self, value: int) -> None:
        if value < 0:
            value = 0

It looks as if a negative input is converted to zero. It is not:

count = BrokenRetryCount(-4)

print(count)  # -4

By the time __init__ is called, int.__new__ has already created an integer object with the value -4. Rebinding the local name value merely changes what that local variable refers to; it cannot replace the immutable value stored in self.

Trying to assign a replacement value to self is no better:

class AlsoBrokenRetryCount(int):
    def __init__(self, value: int) -> None:
        self = 0

Again, this only rebinds a local variable. It does not mutate or replace the returned object.

__init__ can still reject an object by raising an exception, so a constructor that validates only in __init__ may prevent invalid values from reaching a caller. But it cannot normalize an immutable payload, choose which immutable object to return, or correctly pass constructor-specific arguments to the immutable base type. The creation rule belongs in __new__.

The Python Programming FAQ states this directly:

Programming FAQ — Python 3.14.7 documentation

Read the official Python FAQ’s section on controlling data stored in immutable instances. It gives concise examples of a date, integer, and string subclass that each use __new__ to transform or constrain their immutable value during creation.

In the subsection “How can a subclass control what data is stored in an immutable instance?”, read the explanation, then continue through all three examples: FirstOfMonthDate, NamedInt, and TitleStr. Notice that each class transforms or fixes its input before passing the final value to its parent class’s __new__ method.


Enforcing the invariant at the only durable boundary

A well-designed invariant is a condition that holds for every instance that can exist through the class’s public construction API.

For RetryCount, the invariant is:

In less formal terms: every successfully created RetryCount contains an ordinary integer from zero through eight.

The method’s order is deliberate:

def __new__(cls, value: int) -> Self:
    # 1. Validate the input contract.
    ...

    # 2. Validate the domain invariant.
    ...

    # 3. Create the immutable object only after validation.
    return super().__new__(cls, value)

This produces useful failure behavior:

  • Wrong kind of value produces TypeError.
  • Right kind, invalid domain value produces ValueError.
  • Valid value produces a fully valid immutable object.

That distinction is useful later in the FastAPI capstone. Pydantic will handle much request-level parsing and validation, but the domain layer still needs to protect its own rules. An application service should be able to construct RetryCount and rely on the fact that no negative or over-limit value was accepted.


Immutability has two layers

Subclassing int makes the numeric payload immutable:

retries = RetryCount(3)

# No operation can turn this particular object into the integer 4.

However, an immutable base class does not automatically prevent a subclass from gaining mutable extra attributes.

Compare these two types:

class LooseRetryCount(int):
    pass


loose = LooseRetryCount(3)
loose.audit_note = "manually approved"

print(loose)            # 3
print(loose.audit_note) # manually approved

The integer payload remains immutable, but the object’s total state is now mutable. That is rarely desirable for a value object because equality, logging, caching, and serialization can become difficult to reason about.

Our implementation included:

__slots__ = ()

An empty __slots__ declaration prevents a normal instance dictionary from being added to the subclass:

retries = RetryCount(3)
retries.audit_note = "manually approved"
# AttributeError: 'RetryCount' object has no attribute 'audit_note'

This is a useful strengthening measure:

  • int prevents changes to the underlying numeric value.
  • __slots__ = () prevents arbitrary instance attributes.
  • __new__ ensures only invariant-respecting numeric values are created.

Module 2 will examine __slots__, instance dictionaries, and attribute lookup in detail. For now, treat the empty slots declaration as part of making this specific subclass a genuine value object rather than merely an integer with a custom constructor.


A limitation: arithmetic does not preserve your domain type

An immutable value type is not automatically a closed mathematical system. Standard integer arithmetic typically returns a plain int:

retries = RetryCount(8)

next_value = retries + 1

print(next_value)             # 9
print(type(next_value))       # <class 'int'>

Python has not violated the invariant, because next_value is not a RetryCount. It is a normal integer produced by integer addition.

Do not quietly redefine arithmetic just to make this example appear more polished. A method such as __add__ that returns RetryCount would need a carefully specified contract:

  • Should RetryCount(8) + 1 raise ValueError?
  • Should it clamp at 8?
  • Should it return a plain int?
  • What happens for subtraction, multiplication, reflected operations, and sum()?

Those are business semantics, not automatic consequences of subclassing int.

For the job-service domain, make transitions explicit instead:

class RetryCount(int):
    __slots__ = ()

    MIN_VALUE = 0
    MAX_VALUE = 8

    def __new__(cls, value: int) -> Self:
        if isinstance(value, bool) or not isinstance(value, int):
            raise TypeError(
                f"RetryCount requires an int, got {type(value).__name__}"
            )
        if not cls.MIN_VALUE <= value <= cls.MAX_VALUE:
            raise ValueError(
                f"RetryCount must be between "
                f"{cls.MIN_VALUE} and {cls.MAX_VALUE}, got {value}"
            )
        return super().__new__(cls, value)

    def incremented(self) -> Self:
        return type(self)(int(self) + 1)

Now an attempted increment beyond the maximum is rejected by the same constructor invariant:

RetryCount(7).incremented()  # RetryCount(8)
RetryCount(8).incremented()  # ValueError

This method is optional; it becomes appropriate only when the domain explicitly defines “increase retry count by one” as an operation. The key design principle is that all paths that create a RetryCount pass through __new__.


Test the contract, not implementation trivia

A value type earns its complexity only if its behavior is protected by focused tests. These tests describe the constructor contract and the object’s immutability.

import pytest

from app.domain.retry_count import RetryCount


@pytest.mark.parametrize("value", [0, 1, 4, 8])
def test_retry_count_accepts_values_within_bounds(value: int) -> None:
    retries = RetryCount(value)

    assert isinstance(retries, RetryCount)
    assert int(retries) == value


@pytest.mark.parametrize("value", [-1, 9, 100])
def test_retry_count_rejects_values_outside_bounds(value: int) -> None:
    with pytest.raises(ValueError, match="between 0 and 8"):
        RetryCount(value)


@pytest.mark.parametrize("value", [True, False, 1.0, "3"])
def test_retry_count_rejects_non_integer_inputs(value: object) -> None:
    with pytest.raises(TypeError, match="requires an int"):
        RetryCount(value)  # type: ignore[arg-type]


def test_retry_count_rejects_new_instance_attributes() -> None:
    retries = RetryCount(2)

    with pytest.raises(AttributeError):
        retries.audit_note = "unexpected"  # type: ignore[attr-defined]

These tests avoid coupling to the internal detail that the class calls super().__new__. That mechanism is necessary, but it is not the behavior your application relies on. The durable contract is:

  • valid integers in the allowed range construct successfully;
  • invalid integers are rejected;
  • ambiguous non-integers are rejected;
  • the resulting value has no mutable per-instance attribute namespace.

A practical implementation sequence for today is:

  1. Create retry_count.py with the RetryCount implementation.
  2. Create the test file beside it using the examples above.
  3. Run pytest.
  4. Make one intentional defect, such as removing the upper-bound check, and confirm that the test suite catches it.
  5. Restore the invariant before committing.

When to use this pattern

Use __new__ for immutable subclasses when the immutable payload itself needs control at construction time. Typical cases include:

Base typeExample value objectConstruction rule
intRetryCount, PortNumber, PercentBounds, integer-only input
strNormalizedJobName, SlugCanonicalization and format validation
tupleFixed coordinate or identifier partsArity and component validation
datetime.dateMonth boundary dateFixed or normalized calendar component

Avoid subclassing a built-in solely to make a type name more expressive. A small wrapper class, often a frozen dataclass, can be clearer when the object has multiple pieces of state or needs richer behavior. But for a constrained value that should naturally behave like an integer or string, a carefully implemented immutable subclass can be a precise fit.


Takeaways

  • Calling a class normally performs creation through __new__ and then initialization through __init__.
  • For immutable built-in subclasses, the core payload is established in __new__; __init__ is too late to alter or normalize it.
  • A robust __new__ validates input, checks the domain invariant, and calls the immutable base class’s __new__ with the final value.
  • __slots__ = () prevents an immutable built-in subclass from acquiring arbitrary mutable instance attributes.
  • Standard arithmetic on an int subclass usually returns a plain int; use explicit domain operations when results must preserve validation.
  • Tests should protect the constructor’s observable contract: accepted values, rejected values, and immutability constraints.

Next, we will define what it means for two domain objects to represent the same value by implementing value-based equality with __eq__.

Can't find a good explanation? Sign up and we'll make it for you

Sign up