02 — Primitive types
Defines how the FHIR primitive datatypes are represented in Rust.
Applies to every modelled release. Where a release differs, the difference is stated here rather than in the code.
| Release | Primitives |
|---|---|
| R6 | 21 |
| R5 | 21 |
| R4 | 20 (no integer64) |
| R3 | 18 (no integer64, canonical or url) |
| R2 | 18 (no integer64, canonical or url) |
Background#
FHIR primitives are single scalar values with a lowercase initial letter. In FHIR JSON they serialize as bare scalars — a JSON string, number, or boolean — not as objects.
Requirements#
R2.1 Each primitive MUST be a Rust newtype wrapping the smallest faithful inner type, so it serializes transparently as a bare scalar:
FHIR primitive Rust string,code,id,markdown,uri,url,canonical,oid,uuid,base64Binary,xhtmlstruct X(pub String)date,dateTime,instant,timestruct X(pub String)booleanstruct Boolean(pub bool)integerstruct Integer(pub i32)positiveInt,unsignedIntstruct X(pub u32)integer64(R5/R6 only)struct Integer64(pub i64)— serialized as a JSON stringdecimalstruct Decimal— lexical form preserved verbatim (R2.2)canonicalandurlarrived in R4;integer64in R5. A release simply does not generate a primitive it does not define.R2.2
decimalMUST preserve the lexical form of the value it was given — every significant digit, including trailing zeros — and satisfyEq. ItsDefaultis zero, the one primitive whoseDefaultcannot be derived.FHIR treats decimal precision as clinically meaningful:
0.50mmol/L states two significant figures and0.5states one, and a dose of1.000mg is a different claim from1.0mg. A representation that normalizes them is discarding information the sender chose to send.struct Decimal(pub serde_json::Number)does not satisfy this in the crate's default configuration. Withoutserde_json'sarbitrary_precision,Numberis backed byf64, and observed behaviour is:Input Re-serialized 0.500.51.0001.00.12345678901234567890123450.1234567890123456812345678901234567890.51.2345678901234567e+19The crate therefore enables
serde_json/arbitrary_precisionas a non-optional dependency feature, so aNumbercarries the lexeme it was parsed from. Cargo features are additive and a dependent cannot switch one off, which is what turns precision from a default into a guarantee — the formerprecise-decimalopt-in left correctness depending on whether some unrelated crate in the graph happened to enable the same feature, and correctness that arrives by luck is not correctness.Two alternatives were tried and rejected:
serde_json::value::RawValue, storing the lexeme as a string and emitting it as a raw number token. It preserves precision onfrom_strbut fails through#[serde(flatten)], which everyvalue[x]choice element uses (spec 11):flattenbuffers input through serde'sContent, which has no representation for a raw token, so the whole choice variant is silently dropped. LosingObservation.valueQuantityentirely is far worse than rounding it.- Leaving the opt-in and documenting it, which keeps the failure mode silent and the default wrong.
Decimalis consequently a hand-written wrapper (crate::decimal, shared by every release rather than generated once per release) presenting a lexical API —new,as_str,as_f64, lexicalEq, numericPartialOrd— over a precision-preservingNumber.The cost is stated rather than hidden:
arbitrary_precisionis global to the compiled binary, so every crate'sserde_json::Numberin that build becomes lexeme-preserving andNumberarithmetic goes throughas_f64(). For a library whose numbers are doses and lab results, that is the correct side to err on. See spec 13.R2.2a
DecimalMUST offer explicit, lossy-by-request conversions (as_f64,to_string, and aPartialOrdthat compares numerically rather than lexically), so that1.0and1.00compare equal in value while remaining distinguishable on the wire. Equality is lexical; ordering is numeric; both are documented at the type.R2.3
integer64MUST serialize and deserialize as a JSON string (FHIR encodes 64-bit integers as strings so they survive consumers whose numbers are 64-bit floats). Implemented withserde_with'sDisplayFromStr.R2.4 Every primitive MUST derive
Debug, Default, Clone, PartialEq, Eqand beserde(de)serializable. No primitive may containf64/f32.R2.5 Each primitive lives in
fhir-release-N/src/types/<snake>.rsand is re-exported fromfhir-release-N/src/types.rsaspub use <snake>::<Pascal>;.R2.6 Each primitive MUST implement
Validate(spec 07) with its FHIR format constraint where one exists (code,id,oid,uuid,uri,canonical,url); the rest are structurally valid by construction.R2.7 The Rust representation is a design decision the specification JSON does not state, so it MUST live in one table (
codegen::primitives::PRIMITIVES) shared by every release. A release that defines a primitive absent from that table MUST fail generation loudly rather than be guessed at.
Representation notes#
- A single-field tuple struct is serialized by serde as its inner value, so
no
#[serde(transparent)]attribute is required. - Inside
string.rs, refer to the standard library type asstd::string::Stringto avoid shadowing by the newtype.
Rationale#
Newtypes rather than type aliases give each primitive its own Validate impl
and prevent a Code being passed where an Id is meant, at no runtime cost —
the wire form is identical to the bare scalar.
Future work#
- Format validation covers a subset of the primitives; date/time and base64 format checks MAY be added under spec 07.
Acceptance criteria#
- Every primitive its release defines exists as a newtype per R2.1 and
re-exports from
types.rs. Decimalround-trips3.5as JSON3.5; itsDefaultis0. 2a.Decimalround-trips each of0.50,1.000,1e-7,0.1234567890123456789012345, and12345678901234567890.5byte for byte, in a default-feature build, with noserde_jsonfeature enabled anywhere in the dependency graph. 2b.Decimal("1.0") != Decimal("1.00")(lexically distinct) whileDecimal("1.0").partial_cmp(&Decimal("1.00")) == Some(Ordering::Equal)(numerically equal), per R2.2a.Integer64(R5/R6) round-trips9007199254740993as the JSON string"9007199254740993".Code("bad code")andId("bad id!")are reported invalid byValidate.- Every primitive module passes its generated round-trip test.