The validating constructor every Types.* struct builds new/1 on.
@enforce_keys guards presence, not nil
@enforce_keys [:open, :high, :low, :close, ...] makes struct!(Candle, attrs) raise
when a key is absent from attrs. It does nothing when the key is present and
nil: struct!(Candle, open: nil, high: ..., ...) builds without complaint, even
though Candle's own typespec declares open: Decimal.t() — never Decimal.t() | nil.
That gap is not academic. A nil in a field the typespec calls non-nilable is exactly
what a JSON decode bug produces — Map.get(json, "open") on a key the venue renamed
returns nil, silently, and every value downstream stays plausible until the struct
reaches Decimal.compare/2 or similar, several calls away from where the bad data
actually entered. The error message at that point names a Decimal function, not the
venue response that caused it.
What new!/3 adds
struct!/2, plus a check that every field named in required_non_nil is present AND
non-nil, raising ArgumentError naming the offending field when it is not. A caller gets
a message pointing at the exact field and the venue's own module, at the moment the
struct was about to be built — not a FunctionClauseError inside Decimal three frames
later with no indication which field was the problem.
What this deliberately does not do
No coercion, no defaulting, no guessing. A nil where the type forbids one is an
error, full stop — the same rule this whole family applies everywhere else. new!/3
fails closed; it does not repair the input.
Two further values are refused in any field, however deeply nested in a list, tuple
or plain map: a Decimal that is NaN or infinite, which is not a number any field of
these types can carry, and a NaiveDateTime, because every time in this family is a
DateTime and a time with no zone cannot be placed.
Closed vocabularies are checked per type, not here. new!/3 knows nothing about which
field is a :buy | :sell. A type whose typespec states one pipes its result through
one_of!/3 (and positive_integer!/2 for a count), so Fill, Trade, Position and
OrderLeg refuse a side outside what they state. The Decimal fields are not type-checked
for being a Decimal (an integer is accepted, and a venue builds these with integers in its
own tests), but a float is refused at the top level of any field and inside a list or
tuple, because no field of any type is a float and a float has already lost digits. Free-form
maps (details, line_items, metrics) may still hold one: they are the venue's own.
Struct literals still work
Nothing here removes defstruct or @enforce_keys from any Types.* module, and
%Candle{...} remains a valid, unchecked way to build one — internal code and tests that
already construct known-good values by hand are unaffected. new/1 (built on this
module, one per type) is the path a venue package's decoder should prefer, because it
is the one that turns a decode bug into an error at the boundary instead of a crash three
calls downstream.
Summary
Functions
Builds module's struct from attrs, raising ArgumentError naming the field if any of
required_non_nil is absent or explicitly nil.
Returns built if its field holds one of allowed, and raises ArgumentError naming
the field and the value otherwise.
Returns built if its field holds a positive integer, and raises ArgumentError
naming the field and the value otherwise.
Functions
Builds module's struct from attrs, raising ArgumentError naming the field if any of
required_non_nil is absent or explicitly nil.
required_non_nil is ordinarily the type's own @enforce_keys — the fields whose
typespec admits no nil. A type that deliberately allows nil on some enforced keys
(see DpExchange.Core.Types.Order, which enforces presence of seven keys but documents
that six of them may honestly be nil) passes a narrower list naming only the fields
that must never be nil.
Returns built if its field holds one of allowed, and raises ArgumentError naming
the field and the value otherwise.
For a closed vocabulary a type's own typespec states — side: :buy | :sell,
liquidity: :maker | :taker | nil. new!/3 refuses a nil and a NaN; it cannot refuse
side: :long on a Fill, side: "buy" on a Trade, or :buy on a Position, and each
of those is a plausible value with the wrong meaning: a position built :buy is read by
a consumer matching :long | :short as no side at all, or as the wrong one. Include nil
in allowed only where the type's typespec does.
Returns built if its field holds a positive integer, and raises ArgumentError
naming the field and the value otherwise.
For a count or a ratio whose typespec is pos_integer(). A Decimal 1, a float 1.0
and a 0 all read as "one leg" or "no legs" to whoever gets there first.