Variable names do not travel with values. When should domain meaning live in types?

Posted by ResponseSeveral6678@reddit | Python | View on Reddit | 26 comments

A variable name can carry a lot of meaning:

price_in_usd_cents: int

But the value itself is still just int.

Once it is passed to another function, stored in a model, serialized, sent to a queue, or returned from a repository, the original variable name may be gone.

So the domain meaning was attached to a local name, not to the data.

It gets even more visible when working with AI coding agents.

They are very good at following local patterns, but if everything is just int and str, the "density of meaning" is low.

I suspect this may be one reason TS works well with AI-assisted workflows:
type information becomes part of the code context.

Humans see it. IDEs see it. Type checkers see it. AI coding agents see it.

Python has type hints too, but domain meaning often still collapses into primitives.

If the type does not carry the meaning, something else will fill that gap:
names, comments, local conventions, copied patterns, or guesses/assumptions.

A few examples where the IDE is happy, but the semantics are wrong:

# Accidental swap
delay_seconds = 5
timeout_seconds = 30
def schedule_retry(timeout: int, delay: int) -> None: ...
schedule_retry(delay_seconds, timeout_seconds)


# Different units
created_at_microseconds = 1_777_961_207_000_000
retry_delay_seconds = 30
retry_deadline = created_at_microseconds + retry_delay_seconds


# In this example, different developers may imagine different units or precision:
class AuditRecord:
    created_at: int
    updated_at: int

Type lacks meaning and strictness. So, we all tried to solve the problem partially.

- typing.NewType
- small wrapper classes
- dataclasses around one value
- Pydantic custom validators
- plain inheritance from str / int
- UUID-specific helpers

I have also been experimenting, mostly to understand the trade-offs.
The principles I ended up caring about were:
- Strictness:
- no implicit coercion
- invalid input → fail fast
- Runtime type preservation:
- value keeps its domain type, not downgraded to str / int
- Pydantic and pickle preserve the subtype in model/container boundaries
- Static type preservation:
- works correctly with type checkers (mypy / pyright)
- type checkers can distinguish UserInputRaw from UserInputValidated
- Transparency:
- behaves like underlying primitive
- no extra API surface
- Semantic stability:
- arithmetic should downgrade to a primitive
- I would rather create a new domain value explicitly than keep compromised meaning
- Inheritance:
- children can add more meaning
- Minimal API / hot-path friendly:
- no .value or extra attributes

from base_typed_int import BaseTypedInt
from base_typed_string import BaseTypedString
from base_typed_id import BaseTypedId



class UserInputRaw(BaseTypedString):
    """Raw user input before validation."""



class UserInputValidated(BaseTypedString):
    """Validated user input."""



class UnixTimestampSeconds(BaseTypedInt):
    """Wall-clock UNIX timestamp expressed in seconds."""



class DurationSeconds(BaseTypedInt):
    """Duration expressed in seconds."""



class MessageId(BaseTypedId):
    """UUID-based message identifier."""

This approach is not free. It adds more types, more names, and another convention the team has to understand.
So I am trying to understand where people draw the line.

I do not think every primitive should become a domain type.

But some values cross boundaries. How do you handle it in practice?
- typing.NewType
- primitive subclasses
- wrapper value objects
- Pydantic models
- something else?

Where do you draw the line between "this should just be an int / str" and "this deserves a domain type"?