# I Almost Hand-Wrote a FHIR Schema. Then I Found Out I Didn't Have To.

> Source: <https://dev.to/deep-27/i-almost-hand-wrote-a-fhir-schema-then-i-found-out-i-didnt-have-to-30p9>
> Published: 2026-07-15 12:45:39+00:00

A hospital-network client wanted our system to output patient data in actual FHIR format - the standard interoperability format healthcare systems use to talk to each other - instead of whatever shape we felt like inventing. Made total sense from their side, their EHR software only accepts FHIR resources, not our custom JSON. From my side, it meant I now had to get an LLM to produce a `Patient`

resource that was FHIR R4 compliant, field for field.

I opened the FHIR R4 spec page for `Patient`

to see what I was dealing with. Closed the tab about four minutes later. It's not one flat object - names have their own nested structure with `use`

/`family`

/`given`

arrays, telecom is a list of typed contact points, addresses have their own multi-field shape, and half the fields have specific allowed value sets straight out of a separate FHIR terminology spec. This was not going to be a quick `z.object({...})`

.

I started anyway, because what else was I going to do:

``` js
const PatientSchema = z.object({
  resourceType: z.literal("Patient"),
  identifier: z.array(
    z.object({
      system: z.string(),
      value: z.string(),
    })
  ),
  name: z.array(
    z.object({
      use: z.enum(["official", "usual", "nickname", "maiden"]),
      family: z.string(),
      given: z.array(z.string()),
    })
  ),
  telecom: z.array(
    z.object({
      system: z.enum(["phone", "email", "fax"]),
      value: z.string(),
      use: z.enum(["home", "work", "mobile"]).optional(),
    })
  ),
  gender: z.enum(["male", "female", "other", "unknown"]),
  birthDate: z.string(),
  address: z.array(
    z.object({
      use: z.enum(["home", "work", "temp"]).optional(),
      line: z.array(z.string()),
      city: z.string(),
      state: z.string(),
      postalCode: z.string(),
      country: z.string(),
    })
  ),
  // ...and I still hadn't gotten to maritalStatus, communication,
  // contact, generalPractitioner, managingOrganization...
});
```

Two days in and I still wasn't done, and worse, I had no real confidence I'd gotten the parts I *had* written correct. I kept cross-referencing the spec for things like whether `gender`

really only allows those four values or whether I was missing an `unknown`

case, whether `telecom.system`

needed a `url`

variant too, whether I'd nested `name`

right. This was medical data going to an actual hospital system - if I got a field wrong and it silently passed validation because I'd defined it wrong in the first place, that's not a bug someone catches in a code review, that's a bad record in someone's patient chart. That thought alone was enough to make me stop and look for literally any other option before shipping my own guesswork as the source of truth.

I was already using shapecraft for the extraction itself, so on a hunch I checked whether anyone had already solved the "FHIR schema in TypeScript" problem as a package rather than a spec page. Shapecraft ships presets for exactly this:

``` js
import { PatientSchema } from "@aviasole/shapecraft/fhir";
import { generate, openai } from "@aviasole/shapecraft";

const result = await generate(
  openai({ model: "gpt-4o-mini" }),
  PatientSchema,
  patientIntakeText
);

// result.data is a typed, validated FHIR R4 Patient resource
console.log(result.data.name[0].family);
console.log(result.data.telecom[0].system);
```

Same `generate()`

call I already knew from using it elsewhere, same retry/timeout/`guaranteeLevel`

behavior - I didn't have to learn a new API, just point it at a different schema. The nested name/telecom/address structure I'd been hand-rolling badly for two days was already there, already correct, already typed. `Observation`

, `Condition`

, `MedicationRequest`

, and `Encounter`

were sitting right next to it too, which meant when the client's second request came in ("can you also send us the medication list in FHIR") it was a five-minute change instead of another two-day spec-reading marathon.

Deleted my hand-rolled `PatientSchema`

entirely - it wasn't even fully correct, I found out later while diffing it against the preset, I'd missed that `telecom.system`

also allows a `url`

/`sms`

/`pager`

variant I'd never gotten to in the spec. Two days of careful, cautious work, still wrong in a spot I hadn't reached yet.

What actually shipped was a preset import and the same `generate()`

call I'd have written anyway. Kudos to shapecraft for that one - I went from "I might get medical data wrong because I misread a spec doc" to "the schema was already someone else's solved problem" in about ten minutes of searching plus a five-minute swap.

If you're staring down a FHIR resource and reaching for a blank Zod schema, check `@aviasole/shapecraft/fhir`

first. It might already be sitting there.
