Skip to content

TS: generate per-type validate() functions and have profile validators delegate to them #169

@ryukzak

Description

@ryukzak

Background

validate() on generated profiles re-derives and inlines every check (required, choice-required, references, bindings) per profile. Two problems followed from this:

  • Base-resource cardinality not re-stated in a profile's differential was silently dropped, so validate() skipped inherited required fields (e.g. Provenance.target, .recorded). Fixed as a stopgap in TypeSchema/TS: emit validateRequired() for inherited base-required fields #166 via a SnapshotProfileTypeSchema.inheritedRequiredFields side-list + an extra emit loop.
  • That stopgap only carries required-ness. The same dropped base info affects inherited choice-required, references, and bindings — each would need its own parallel side-list and emit path. The duplicated emit path also has a known limitation: a base choice declaration (value[x], 1..1) inherited but not re-stated emits validateRequired("value[x]") instead of validateChoiceRequired(...) — a check against a property that never exists in FHIR JSON.

The root issue is layering: profile validate() reads a constraint-only view but needs the effective (base ⊕ differential) view, and there's no base-resource validator to lean on.

Proposal

Generate a standalone validate<Type>(res, profileName?) free function per specialization (resource/complex-type), and have profile validators delegate to it:

// generated for the base resource
export const validateProvenance = (res, profileName = "Provenance") => ({ errors: [...], warnings: [...] })

// generated profile class
validate(): { errors: string[]; warnings: string[] } {
    const base = validateProvenance(this.resource, "PraxisProposalProvenance")
    return { errors: [...base.errors, ...profileSpecificChecks], warnings: [...base.warnings] }
}

Free functions (not methods) because plain generated types are interface/type with nowhere to hang a method — and because per-type runtime validation is independently useful: the type system can't enforce runtime presence, array cardinality, value/fixed constraints, large-ValueSet bindings, reference targets, or FHIRPath invariants.

The single emitter (collectRegularFieldValidation + the choice-declaration branch) is reused for both base and profile, so choice/reference/binding handling is uniform — the #166 choice-declaration bug disappears.

Semantics

  • Base cardinality is a non-negotiable floor. A standalone validate<Type> has no profile to relax it. Profiles only add constraints on top. This removes inheritedRequiredFields and the base-level min:0 reset entirely; intra-chain relaxation (a leaf relaxing what an intermediate profile tightened, still ≥ base floor) stays handled in the flat constraint snapshot.
  • Profile delegation = base() ++ profileChecks; no subtraction needed.

Scope

  • Opt-in flag (sibling of generateProfile, e.g. generateValidators on the writer options) — generating validators for emitted types roughly doubles output, and many consumers want types only. Emit per-type so bundlers can drop unused validators.
  • Top-level fields first. Nested backbone required fields and FHIRPath invariants are a deliberately separate later milestone — do not let this slide into a full structural validator in v1.

What this replaces

Acceptance criteria

  • validate<Type> free function generated per specialization when the flag is on.
  • Profile validate() delegates to its base validator and appends only differential checks.
  • Inherited required, choice-required, reference, and binding constraints all validated via the shared emitter.
  • Behaviour-level tests in examples/ (run generated validate(), assert on errors) covering an inherited required field not re-stated by the profile.
  • Opt-in flag documented; default behaviour unchanged when off.

Open questions

  • Flag name + default (generateValidators?).
  • File layout: one validators.ts per package, or co-located with each type?
  • Should validate<Type> be emitted unconditionally for any type that has a profile in the output, even if the type itself is tree-shaken from the surface?

Follow-up to #166, which lands the targeted fix for inherited required fields. This issue tracks the broader redesign that subsumes it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions