Parse concise medication sigs into FHIR R5 Dosage JSON
npm install ezmedicationinputezmedicationinput parses concise clinician shorthand medication instructions and produces FHIR R5 Dosage JSON. It is designed for use in lightweight medication-entry experiences and ships with batteries-included normalization for common Thai/English sig abbreviations.
- Converts shorthand strings (e.g. 1x3 po pc, 500 mg po q6h prn pain) into FHIR-compliant dosage JSON.
- Emits timing abbreviations (timing.code) and repeat structures simultaneously where possible.
- Maps meal/time blocks to the correct Timing.repeat.when EventTiming codes and can auto-expand AC/PC/C into specific meals.
- Outputs SNOMED CT route codings (while providing friendly text) and round-trips known SNOMED routes back into the parser.
- Auto-codes common PRN (as-needed) reasons and additional dosage instructions while keeping the raw text when no coding is available.
- Understands ocular and intravitreal shorthand (OD/OS/OU, LE/RE/BE, IVT*, VOD/VOS, etc.) and warns when intravitreal instructions omit an eye side.
- Parses fractional/ minute-based intervals (q0.5h, q30 min, q1/4hr) plus dose and timing ranges.
- Supports extensible dictionaries for routes, units, frequency shorthands, and event timing tokens.
- Applies medication context to infer default units when they are omitted.
- Surfaces warnings when discouraged tokens (QD, QOD, BLD) are used and optionally rejects them.
- Generates upcoming administration timestamps from FHIR dosage data via nextDueDoses using configurable clinic clocks.
- Auto-codes common body-site phrases (e.g. "left arm", "right eye") with SNOMED CT anatomy concepts and supports interactive lookup flows for ambiguous sites.
``bash`
npm install ezmedicationinput
`ts
import { parseSig } from "ezmedicationinput";
const result = parseSig("1x3 po pc", { context: { dosageForm: "tab" } });
console.log(result.fhir);
`
Example output:
`json`
{
"text": "1 tablet by mouth three times daily after meals",
"timing": {
"code": { "coding": [{ "code": "TID" }], "text": "TID" },
"repeat": {
"frequency": 3,
"period": 1,
"periodUnit": "d",
"when": ["PC"]
}
},
"route": { "text": "by mouth" },
"doseAndRate": [{ "doseQuantity": { "value": 1, "unit": "tab" } }]
}
parseSig identifies PRN (as-needed) clauses and trailing instructions, then
codes them with SNOMED CT whenever possible.
`ts
const result = parseSig("1 tab po q4h prn headache; do not exceed 6 tabs/day");
result.fhir.asNeededFor;
// → [{
// text: "headache",
// coding: [{
// system: "http://snomed.info/sct",
// code: "25064002",
// display: "Headache"
// }]
// }]
result.fhir.additionalInstruction;
// → [{ text: "Do not exceed 6 tablets daily" }]
`
Customize the dictionaries and lookups through ParseOptions:
`ts`
parseSig(input, {
prnReasonMap: {
migraine: {
text: "Migraine",
coding: {
system: "http://snomed.info/sct",
code: "37796009",
display: "Migraine"
}
}
},
prnReasonResolvers: async (request) => terminologyService.lookup(request),
prnReasonSuggestionResolvers: async (request) => terminologyService.suggest(request),
});
Use {reason} in the sig string (e.g. prn {migraine}) to force a lookup evenParseResult.meta.normalized.additionalInstructions
when a direct match exists. Additional instructions are sourced from a built-in
set of SNOMED CT concepts under 419492006 – Additional dosage instructions and
fall back to plain text when no coding is available. Parsed instructions are
also echoed in for quick UI
rendering.
When a PRN reason cannot be auto-resolved, any registered suggestion resolvers
are invoked and their responses are surfaced through
ParseResult.meta.prnReasonLookups so client applications can prompt the user
to choose a coded concept.
Use suggestSig to drive autocomplete experiences while the clinician isParseOptions
typing shorthand medication directions (sig = directions). It returns an array
of canonical direction strings and accepts the same context pluslimit
a and custom PRN reasons.
`ts
import { suggestSig } from "ezmedicationinput";
const suggestions = suggestSig("1 drop to od q2h", {
limit: 5,
context: { dosageForm: "ophthalmic solution" },
});
// → ["1 drop oph q2h", "1 drop oph q2h prn pain", ...]
`
Highlights:
- Recognizes plural units and their singular counterparts (tab/tabs,puff
/puffs, mL/millilitres, etc.) and normalizes spelled-out metric,micrograms
SI-prefixed masses/volumes (, microliters, nanograms,liters
, kilograms, etc.) alongside household measures like teaspoontablespoons
and (set allowHouseholdVolumeUnits: false to omit them).to
- Keeps matching even when intermediary words such as , in, or ocularod
site shorthand (, os, ou) appear in the prefix.q
- Emits dynamic interval suggestions, including arbitrary cadencesq4-6h
and common range patterns like .1 tab po morn hs
- Supports multiple timing tokens in sequence (e.g. ).prnReasons
- Surfaces PRN reasons from built-ins or custom entries while
preserving numeric doses pulled from the typed prefix.
The library exposes default dictionaries in maps.ts for routes, units, frequencies (Timing abbreviations + repeat defaults), and event timing tokens. You can extend or override them via the ParseOptions argument.
Key EventTiming mappings include:
| Token(s) | EventTiming |
|-----------------|-------------|
| ac | ACpc
| | PCwm
| , with meals | Cpc breakfast
| | PCMpc lunch
| | PCDpc dinner
| | PCVbreakfast
| , bfast, brkfst, brk | CMlunch
| , lunchtime | CDdinner
| , dinnertime, supper, suppertime | CVam
| , morning | MORNnoon
| , midday, mid-day | NOONafternoon
| , aft | AFTpm
| , evening | EVEnight
| | NIGHThs
| , bedtime | HS
When when is populated, timeOfDay is intentionally omitted to stay within HL7 constraints.
Routes always include SNOMED CT codings. Every code from the SNOMED Route of Administration value set is represented so you can confidently pass parsed results into downstream FHIR services that expect coded routes.
Spelled-out application sites are automatically coded when the phrase is known to the bundled SNOMED CT anatomy dictionary. The normalized site text is also surfaced in Dosage.site.text and in the ParseResult.meta.normalized.site object.
`ts
import { parseSig } from "ezmedicationinput";
const result = parseSig("apply cream to left arm twice daily");
result.fhir.site?.coding?.[0];
// → { system: "http://snomed.info/sct", code: "368208006", display: "Left upper arm structure" }
`
When the parser encounters an unfamiliar site, it leaves the text untouched and records nothing in meta.siteLookups. Wrapping the phrase in braces (e.g. apply to {mole on scalp}) preserves the same parsing behavior but flags the entry as a probe so meta.siteLookups always contains the request. This allows UIs to display lookup widgets even before a matching code exists. Braces are optional when the site is already recognized—they simply make the clinician's intent explicit.
Unknown body sites still populate Dosage.site.text and ParseResult.meta.normalized.site.text, allowing UIs to echo the verbatim phrase while terminology lookups run asynchronously.
You can extend or replace the built-in codings via ParseOptions:
`ts
import { parseSigAsync } from "ezmedicationinput";
const result = await parseSigAsync("apply to {left temple} nightly", {
siteCodeMap: {
"left temple": {
coding: {
system: "http://example.org/custom",
code: "LTEMP",
display: "Left temple"
},
aliases: ["temporal region, left"],
text: "Left temple"
}
},
// any overrides that the user explicitly selected
siteCodeSelections: [
{
canonical: "scalp",
resolution: {
coding: {
system: "http://snomed.info/sct",
code: "39937001",
display: "Scalp structure"
},
text: "Scalp"
}
}
],
siteCodeResolvers: async (request) => {
if (request.canonical === "mole on scalp") {
return {
coding: { system: "http://snomed.info/sct", code: "39937001", display: "Scalp structure" },
text: request.text
};
}
return undefined;
},
siteCodeSuggestionResolvers: async (request) => {
if (request.isProbe) {
return [
{
coding: { system: "http://snomed.info/sct", code: "39937001", display: "Scalp structure" },
text: "Scalp"
},
{
coding: { system: "http://snomed.info/sct", code: "280447003", display: "Temple region of head" },
text: "Temple"
}
];
}
return undefined;
}
});
result.meta.siteLookups;
// → [{ request: { text: "left temple", isProbe: true, ... }, suggestions: [...] }]
`
- siteCodeMap lets you supply deterministic overrides for normalized site phrases.aliases
- Entries accept an array so punctuation-heavy variants (e.g., "first bicuspid, left") can resolve to the same coding.siteCodeResolvers
- (sync or async) can call external services to resolve sites on demand.siteCodeSuggestionResolvers
- return candidate codes; their results populate meta.siteLookups[0].suggestions.siteCodeSelections
- let callers override the automatic match for a detected phrase or range—helpful when a clinician chooses a bundled SNOMED option over a custom override.SiteCodeLookupRequest
- Each resolver receives the full , including the original input, the cleaned site text, and a { start, end } range you can use to highlight the substring in UI workflows.parseSigAsync
- behaves like parseSig but awaits asynchronous resolvers and suggestion providers.
#### Site resolver signatures
`tstext
export interface SiteCodeLookupRequest {
originalText: string; // Sanitized phrase before brace/whitespace cleanup
text: string; // Brace-free, whitespace-collapsed site text
normalized: string; // Lower-case variant of {placeholder}
canonical: string; // Normalized key for dictionary lookups
isProbe: boolean; // True when the sig used syntaxinputText
inputText: string; // Full sig string the parser received
sourceText?: string; // Substring extracted from sourceText
range?: { start: number; end: number }; // Character range of
}
export type SiteCodeResolver = (
request: SiteCodeLookupRequest
) => SiteCodeResolution | null | undefined | Promise
export type SiteCodeSuggestionResolver = (
request: SiteCodeLookupRequest
) =>
| SiteCodeSuggestionsResult
| SiteCodeSuggestion[]
| SiteCodeSuggestion
| null
| undefined
| Promise
`
SiteCodeResolution, SiteCodeSuggestion, and SiteCodeSuggestionsResult mirror the values shown in the example above. Resolvers can use request.range (start inclusive, end exclusive) together with request.sourceText to paint highlights or replace the detected phrase in client applications.
Consumers that only need synchronous resolution can continue calling parseSig. If any synchronous resolver accidentally returns a Promise, an error is thrown with guidance to switch to parseSigAsync.
You can specify the number of times (total count) the medication is supposed to be used by ending with for {number} times, x {number} doses, or simply x {number}
parseSig accepts a ParseOptions object. Highlights:
- context: optional medication context (dosage form, strength, containernull
metadata) used to infer default units when a sig omits explicit units. Pass
to explicitly disable context-based inference.smartMealExpansion
- : when true, generic AC/PC/C meal abbreviations and1x3
cadence-only instructions expand into concrete with-meal EventTiming
combinations (e.g. → breakfast/lunch/dinner). This also respectscontext.mealRelation
when provided and only applies to schedules with fourtwoPerDayPair
or fewer daily doses.
- : controls whether 2× AC/PC/C doses expand to breakfast+dinner (default) or breakfast+lunch.assumeSingleDiscreteDose
- : when true, missing discrete doses (such aseventClock
tablets or capsules) default to a single unit when the parser can infer a
countable unit from context.
- : optional map of EventTiming codes to HH:mm strings that drives chronological ordering of parsed when values.allowHouseholdVolumeUnits
- : defaults to true; set to false to ignorerouteMap
teaspoon/tablespoon units during parsing and suggestions.
- Custom , unitMap, freqMap, and whenMap let you augment the built-in dictionaries without mutating them.siteCodeSelections
- override automatic site resolution for matching phrases or ranges so user-picked suggestions stick when re-parsing a sig.
nextDueDoses produces upcoming administration timestamps from an existing FHIR Dosage. Supply the evaluation window (from), optionally the order start (orderedAt), and clinic clock details such as a time zone and event timing anchors. When a Timing.repeat.count cap exists and prior occurrences have already been administered, pass priorCount to indicate how many doses were consumed before the from timestamp so remaining administrations are calculated correctly without re-traversing the timeline.
`ts
import { EventTiming, nextDueDoses, parseSig } from "ezmedicationinput";
const { fhir } = parseSig("1x3 po pc", { context: { dosageForm: "tab" } });
const schedule = nextDueDoses(fhir, {
orderedAt: "2024-01-01T08:15:00Z",
from: "2024-01-01T09:00:00Z",
limit: 5,
timeZone: "Asia/Bangkok",
eventClock: {
[EventTiming.Morning]: "08:00",
[EventTiming.Noon]: "12:00",
[EventTiming.Evening]: "18:00",
[EventTiming["Before Sleep"]]: "22:00",
[EventTiming.Breakfast]: "08:00",
[EventTiming.Lunch]: "12:30",
[EventTiming.Dinner]: "18:30"
},
mealOffsets: {
[EventTiming["Before Meal"]]: -30,
[EventTiming["After Meal"]]: 30
},
frequencyDefaults: {
byCode: { BID: ["08:00", "20:00"] }
}
});
// → ["2024-01-01T12:30:00+07:00", "2024-01-01T18:30:00+07:00", ...]
`
Key rules:
- when values map to the clinic eventClock. Generic meal codes (AC, PC, C) use mealOffsets against breakfast/lunch/dinner anchors.repeat.period
- Interval-based schedules ( + periodUnit) step forward from orderedAt, respecting dayOfWeek filters.BID
- Pure frequency schedules (, TID, etc.) fall back to clinic-defined institution times.
- All timestamps are emitted as ISO strings that include the clinic time-zone offset.
from is required and marks the evaluation window. orderedAt is optional—when supplied it acts as the baseline for interval calculations; otherwise the from timestamp is reused. The options bag also accepts timeZone, eventClock, mealOffsets, and frequencyDefaults at the top level (mirroring the legacy config object). limit defaults to 10 when omitted.
calculateTotalUnits computes the total amount of medication (and optionally the number of containers) required for a specific duration. It accounts for complex schedules, dose ranges (using the high value), and unit conversions between doses and containers.
`ts
import { calculateTotalUnits, parseSig } from "ezmedicationinput";
const { fhir } = parseSig("1x3 po pc");
const result = calculateTotalUnits({
dosage: fhir,
from: "2024-01-01T08:00:00Z",
durationValue: 7,
durationUnit: "d",
timeZone: "Asia/Bangkok",
context: {
containerValue: 30, // 30 tabs per bottle
containerUnit: "tab"
}
});
// → { totalUnits: 21, totalContainers: 1 }
`
It can also handle strength-based conversions (e.g. calculating how many 100mL bottles are needed for a 500mg TID dose of a 250mg/5mL suspension).
Use parseStrength to normalize medication strength strings into FHIR-compliant Quantity or Ratio structures. It understands percentages, ratios, and composite strengths.
`ts
import { parseStrength } from "ezmedicationinput";
// Percentage (infers g/100mL for liquids or g/100g for solids)
parseStrength("1%", { dosageForm: "cream" });
// → { strengthRatio: { numerator: { value: 1, unit: "g" }, denominator: { value: 100, unit: "g" } } }
// Ratios
parseStrength("250mg/5mL");
// → { strengthRatio: { numerator: { value: 250, unit: "mg" }, denominator: { value: 5, unit: "mL" } } }
// Composite (sums components into a single ratio)
parseStrength("875mg + 125mg");
// → { strengthQuantity: { value: 1000, unit: "mg" } }
// Simple Quantity
parseStrength("500mg");
// → { strengthQuantity: { value: 500, unit: "mg" } }
`
parseStrengthIntoRatio is also available if you specifically need a FHIR Ratio object regardless of the denominator.
The parser recognizes ophthalmic shorthands such as OD, OS, OU, LE, RE, and BE, as well as intravitreal-specific tokens including IVT, IVTOD, IVTOS, IVTLE, IVTBE, VOD, and VOS. Intravitreal sigs require an eye side; the parser surfaces a warning if one is missing so downstream workflows can prompt the clinician for clarification.
- QD (daily)QOD
- (every other day)BLD
- / B-L-D (with meals)
By default these are accepted with a warning via ParseResult.warnings. Set allowDiscouraged: false in ParseOptions to reject inputs containing them.
Run the Vitest test suite:
`bash``
npm test
MIT