Types¶
Haiway's types package contains the low-level primitives that support State, schema generation,
metadata handling, and immutable value objects. Most applications use these types indirectly through
State, but understanding them helps when you need custom metadata, three-state optional values, or
standalone immutable containers.
Overview¶
The package groups into four areas:
- JSON-like value aliases:
RawValue,BasicValue,BasicObject,FlatObject - Sentinel and defaults:
MISSING,Missing,Default,DefaultValue - Immutable containers:
Immutable,Map,Meta - Annotation metadata:
Alias,Description,Specification,TypeSpecification
All of them are exported from haiway.
JSON-Compatible Value Aliases¶
Use the aliases from haiway.types.basic when you need precise public types for JSON-like payloads:
RawValueisstr | float | int | bool | NoneBasicValueis recursive: raw values, nested mappings, and nested sequencesBasicObjectisMapping[str, BasicValue]FlatObjectisMapping[str, RawValue]
FlatObject is useful for headers, message attributes, and similar payloads where nested structures
are intentionally forbidden.
Converting to Basic Values¶
State.to_basic_object(), State.to_json(), and Meta validation all share one conversion into
this representation. Each leaf is encoded to the spelling the matching attribute validation reads
back, so a converted value can be validated into the type it came from:
| Type | Basic value |
|---|---|
str, int, float, bool |
unchanged |
None, StrEnum, IntEnum |
unchanged |
UUID |
str(value) |
datetime, date, time |
value.isoformat() |
Path |
value.as_posix() |
bytes, bytearray, memoryview |
base64 encoded str |
Nested State instances, dataclasses, mappings, sequences and sets are converted as the value graph
is traversed, into Map and tuple instances. Mapping keys go through the same table and are then
spelled the way JSON spells them - a str result is used as is, int and float become
str(key), and bool and None become "true", "false" and "null". That is the spelling
json.dumps gives those keys on its own, so payloads which already encoded keep the output they
had.
from collections.abc import Mapping
from datetime import UTC, datetime
from uuid import UUID
from haiway import State
class Event(State):
identifier: UUID
created: datetime
class Journal(State):
events: Mapping[UUID, Event]
event = Event(identifier=UUID(int=1), created=datetime(2026, 1, 2, tzinfo=UTC))
event.to_basic_object()
# {"identifier": "00000000-0000-0000-0000-000000000001", "created": "2026-01-02T00:00:00+00:00"}
# keys are converted too, so a mapping json could not key encodes now
Journal(events={UUID(int=1): event}).to_json()
# '{"events": {"00000000-0000-0000-0000-000000000001": {"identifier": "000...", ...}}}'
Anything else - a plain Enum, a Decimal, MISSING - has no basic spelling the validation would
read back. Rather than encoding it into a value which could only fail on the way in,
to_basic_object raises TypeError naming the path and the type, never the value. to_json leaves
such values unchanged instead, so a custom encoder_class still gets to handle them.
Missing Values with MISSING¶
MISSING is Haiway's explicit "not provided" sentinel. It is different from None:
Nonemeans a value was provided and that value is nullMISSINGmeans no value was provided at all
from haiway import MISSING, Missing, is_missing, not_missing, unwrap_missing
def normalize(value: str | Missing) -> str:
if is_missing(value):
return "fallback"
assert not_missing(value)
return value.upper()
assert unwrap_missing(MISSING, default="fallback") == "fallback"
Use Missing in state field types when you need three states: present, null, and omitted.
Defaults with Default¶
Default(...) is a typed wrapper used by Immutable and State fields.
from uuid import uuid4
from haiway import Default, State
class RequestContext(State):
request_id: str = Default(factory=lambda: uuid4().hex)
retries: int = Default(3)
Environment-backed defaults name the variable with env. default is the fallback used when the
variable is not set, and mapping converts the raw string to the type of the field:
from haiway import Default, State, parse_bool
class ServiceConfig(State):
host: str = Default(env="SERVICE_HOST", default="localhost")
port: int = Default(env="SERVICE_PORT", mapping=int, default=8080)
tracing: bool = Default(env="SERVICE_TRACING", mapping=parse_bool, default=False)
token: str = Default(env="SERVICE_TOKEN") # required - no fallback
Important behavior:
- Literal defaults are reused as-is
- Factories are called for each new instance
- Environment-backed defaults are read during instance construction, not at import time, so
load_env()applies regardless of import order - Without a
default, an unset variable makes the field required and construction reports the variable by name Default(...)is a field specifier, not a runtime descriptor
Immutable¶
Immutable is Haiway's small frozen-record base class. Subclasses declare attributes with normal
type annotations, and the metaclass:
- collects annotated fields
- creates
__slots__ - sets
__match_args__ - resolves
Default(...)values - marks subclasses as
final
from haiway import Default, Immutable
class RetryPolicy(Immutable):
attempts: int = Default(3)
backoff_seconds: float
Instances cannot be modified after construction. copy.copy() and copy.deepcopy() return the same
instance because the object is immutable.
Map¶
Map[K, V] is an immutable dict subclass with JSON helpers:
from haiway import Map
mapping = Map({"a": 1})
merged = mapping | {"b": 2}
assert isinstance(merged, Map)
assert merged == {"a": 1, "b": 2}
Mutation methods such as update, pop, and item assignment raise AttributeError.
Use Map when you want an immutable mapping outside State, or when you want to document that a
function returns a read-only mapping value concretely.
Meta¶
Meta is a specialized immutable metadata mapping built on the same JSON-compatible value model. It
adds:
- validation and normalization through
Meta.of(...),Meta.from_mapping(...), andMeta.from_json(...) - convenience accessors such as
.kind,.name,.description,.identifier,.error,.created,.last_updated, and.tags - typed getters for arbitrary keys -
.get_str(...),.get_int(...),.get_float(...),.get_bool(...),.get_uuid(...), and.get_datetime(...)- each raisingTypeErrorwhen the stored value has a different type - builder-style methods such as
.with_tags(...),.with_error(...),.with_created(...),.merged_with(...), and.excluding(...)
from haiway import Meta
meta = Meta.of(
kind="dataset",
tags=("exports", "pii"),
payload={"owner": "ops", "versions": [1, 2]},
)
assert meta.tags == ("exports", "pii")
assert meta["payload"]["versions"] == (1, 2)
Normalization rules:
- lists become tuples
- nested mappings become
Map - values with a basic spelling are coerced to it - a
datetimebecomes its ISO string, aUUIDbecomesstr(value)- see Converting to Basic Values - values without one raise
TypeError - keys stay strictly
str, there is no spelling to coerce them to
Meta.empty is a shared empty instance returned by Meta.of(None).
Accessors normalize on read rather than storing rich types: .error stores the stringified message
under "error" and returns it wrapped in a plain Exception, so the original exception type does
not survive a round trip.
meta = Meta.empty.with_error(ValueError("quota exceeded"))
assert meta["error"] == "quota exceeded"
assert isinstance(meta.error, Exception)
One subtlety matters: direct Meta({...}) construction does not recursively validate or normalize
values on its own. Use the factory helpers when the input comes from user code, JSON, or mutable
objects.
Annotation Metadata¶
Haiway consumes several types through typing.Annotated[...] when resolving State fields:
Alias("external_name")changes the externally exposed field nameDescription("...")adds human-readable schema/documentation textSpecification({...})provides a manual JSON-schema-like overrideMeta.of(...)attaches structured metadata to the resolved attribute definitionValidator(callable)applies an additional validation or coercion step before the base type validation runsVerifier(callable)applies an additional check after the base type has been validated
from typing import Annotated
from haiway import Alias, Description, Meta, Specification, State
class Invoice(State):
customer_id: Annotated[
str,
Alias("customer"),
Description("Public customer identifier"),
Meta.of(tags=("billing",)),
Specification({"type": "string"}),
]
These annotations feed both runtime validation metadata and generated state schemas.
Type Specifications¶
TypeSpecification is a typed union of JSON-schema-like TypedDict shapes. Specification is a
small immutable wrapper around one of those shapes so it can be attached inside
typing.Annotated[...].
This is intentionally lightweight:
- Haiway keeps the structure typed
- schema fragments are composed by the attribute system
Specification(...)does not deeply validate every schema keyword
Use it when inference is insufficient or when a type is intentionally represented differently in the generated schema.