Skip to content
Pyrula

Modeling Types

Two ways to make your domain types harder to misuse.

newtype wraps a base type so a UserId isn’t interchangeable with a bare int, even though it behaves like one at runtime. It stops the class of bug where you pass an order id where a user id was expected.

from pyrula import newtype, newtype_strict
UserId = newtype("UserId", int)
UserId(1) # 1, but typed as UserId
Email = newtype_strict("Email", str, validate=lambda s: "@" in s)
Email("a@example.com") # ok
Email("nope") # raises ValueError

newtype_strict adds a validator. Pass err_msg to control the failure message.

@sealed marks a closed set of cases. @case declares each variant as an immutable dataclass. Together they give you a union you can pattern match over, with match raising if you forget a case.

from pyrula import sealed, case, match, on
@sealed
class Shape:
pass
@case
class Circle(Shape):
radius: float
@case
class Rect(Shape):
w: float
h: float
def area(shape: Shape) -> float:
return match(shape)(
on(Circle, lambda c: 3.14159 * c.radius ** 2),
on(Rect, lambda r: r.w * r.h),
)
area(Circle(2.0)) # 12.566...

match(value)(...) checks each on(Type, handler) in order and runs the first whose type matches. No branch matches, it raises rather than returning None, so a missed case fails loudly instead of slipping through.

@sealed plus @case with match/on is Scala’s sealed trait with case class variants and a match expression; an unmatched case raising mirrors a non-exhaustive Scala match under -Xfatal-warnings. newtype fills the role of Scala 3 opaque types (or value classes / tagged types) for a distinct type that erases to its base at runtime.