One Field, Three Tries, Two Honest Nulls
Resolving which value enum decodes a HealthKit category type took three attempts. The first resolved 29 of 30 and was wrong in the worst way.
Our HealthKit dataset is generated by reading Apple's own documentation JSON for four identifier families, and it produced 240 identifiers when we last ran it on 2026-08-28. Getting one field right took three attempts, and the first attempt was the dangerous one: it resolved 29 of 30 rows and was wrong.
This post is about that field, a regex that swallowed heart rate's unit, and a row counter that lied. All three are the ordinary way a derived dataset goes bad, which is the reason for writing them down.
The field: which enum decodes a category sample#
HealthKit has 30 category type identifiers. A category sample stores an integer. That integer is meaningless until you know which enum decodes it — whether 2 means a sleep stage, a flow level, or nothing at all.
So for the identifier reference, naming that enum is the single most useful thing we can publish about a category type. Get it wrong and a reader charts the wrong vocabulary against the right numbers, which is a bug that produces no error and no empty state. It just quietly means something else.
Apple's documentation pages reference the relevant enum. They also reference other things.
Attempt one: take the first enum on the page#
The obvious rule: scan the page's reference block for symbols starting with
HKCategoryValue, take the first one.
| Attempt | Rule | Category types resolved |
|---|---|---|
| 1 | First HKCategoryValue* symbol referenced on the page | 29 of 30 |
| 2 | Only an enum whose name matches the type exactly | far fewer, and structurally biased |
| 3 | The type's own matching enum name, or the generic enum | 28 of 30 |
29 of 30. Near-total coverage, one honest gap, ship it.
It was wrong, and the coverage number is what made it look right.
Apple's reference blocks contain cross-links to neighbouring symbols, not just
the one that applies. The pregnancy page references a bleeding-related value
enum that has nothing to do with decoding a pregnancy sample. The
bleedingAfterMenopause page references the menopausal state enum. Under the
first-match rule, whichever of those the reference block happened to yield
first would have been published as the decoding rule for that type.
That is the worst possible failure shape. It does not produce a gap someone notices. It produces a confident, specific, plausible wrong answer in a field where the reader has no way to check it without going to Apple's docs — which is the thing they came here to avoid doing.
The high coverage number was not evidence the rule worked. It was evidence the rule always found something, which is a different property and the one we accidentally measured.
Attempt two: demand an exact name match#
Overcorrect. Accept an enum only if its name is exactly the type's own name
prefixed with HKCategoryValue.
That kills the cross-link problem completely, and it resolved far fewer types — few enough that the field would have been close to useless. Worse, the failures were not random. Every type that carries no meaningful value at all, the event-style ones like handwashing, toothbrushing, high and low heart rate notifications and mindful sessions, went null.
Those types are not undocumented. They use the generic HKCategoryValue, whose
only case is "not applicable". Apple often references it as that member rather
than as the bare enum name, so a strict name comparison misses it entirely.
A rule that systematically drops one legitimate pattern is not conservative. It is wrong in a tidier direction, and it would have told readers that a large, coherent group of types had no decoding rule when in fact they have the simplest one there is.
Attempt three: own name, or the generic#
The rule we shipped accepts exactly two things: an enum named after the type
itself, or the generic HKCategoryValue — counting both the bare name and the
.notApplicable member form. Anything else yields null.
That resolves 28 of 30, and leaves 2 nulls: bleedingAfterMenopause and
hypertensionEvent.
Those two are the point of the whole exercise. Both are recent additions —
hypertensionEvent arrived at iOS 26.2, bleedingAfterMenopause at iOS 27.0 —
and both are among the 4 identifiers Apple lists with no abstract at all. There
is a defensible guess available for each. We do not publish it, because a
sourced null and a plausible guess are indistinguishable to a reader once they
are both in the same column, and the moment we ship one guess the other 28
values stop being trustworthy too.
Note also that this rule reports lower coverage than the first one. A field getting worse on its headline metric while getting more correct is a normal outcome, and any process that treats coverage as the goal will reject the better rule every time.
The regex that ate heart rate#
Separately, quantity types state a unit family in prose — "uses count units", "uses energy units" — and the generator extracts it with a pattern.
The character class in that pattern excluded /. Compound units contain one.
count/time did not match, and so heart rate, the single most-read quantity
type on any health platform, rendered with no unit family at all. Silently. No
error, no warning, just an empty cell in a table nobody had audited row by row.
Widening the class to allow / took the resolved count to 116 of 120.
Four quantity types still state no unit family:
appleSleepingBreathingDisturbances, estimatedWorkoutEffortScore,
physicalEffort and workoutEffortScore. Those are correct nulls — Apple does
not name a unit family for them, sometimes describing a unit in prose without
naming a family, and the pattern deliberately does not match prose. A fragment
of a sentence in a unit column is worse than an empty one.
The lesson here is not "write better regexes". It is that a silent extraction failure looks exactly like a genuine absence, so the only way to find one is to check the rows you would bet on. We found this by looking at heart rate and noticing the obvious thing was missing.
The counter that lied#
The third bug was the cheapest and the most reassuring. When a second dataset was added to the same generated file — the HealthKit error cases, published at the HKError reference — a row counter picked up rows that did not belong to it and reported an inflated total.
The build's own row-count assertion caught it. Every generator here declares how many rows it expects and exits non-zero rather than publishing a file that parsed short or long. That assertion exists precisely because a truncated dataset is invisible in review: the file is well-formed, the types check, the page renders, and a slice of reality is simply missing.
This is the one class of content bug that automation genuinely solves, and it is worth building before you need it. The rest of our standard, including what it cannot check, is in how we verify.
What to take from this#
If you are generating a dataset from someone else's documentation, three rules came out of this that we would now apply from the start.
Coverage is not correctness. A rule that resolves nearly everything may simply be one that always finds something. Test it against the rows most likely to be cross-linked or ambiguous, not against the total.
A value you cannot source becomes null. Never a default, never an inference, never the most likely option. Where a value is inferred from prose rather than copied from a field, keep the sentence it came from so the inference can be audited later.
Make a short parse fail the build. Declare the expected row count and exit non-zero. It costs an afternoon and it is the only mechanism here that catches a problem before a reader does.
The dataset this post is about is published under CC BY on the datasets page, nulls included, so you can check the two empty enum cells and the four empty unit cells yourself. The wider standard is on the methodology page, and if you want the practical consequence of these fields, the aggregation and unit columns are what decide whether you sum or average a type — covered in sum or average, with the reproductive-health types in the menstrual cycle API reference.
Frequently asked questions
- Why do some HealthKit category types have no value enum listed?
- Because Apple's documentation for those types does not reference one in a form we could resolve without guessing, and a guess in this field ships a wrong decoding rule to whoever trusts it. Two category types in our dataset carry null for that reason. Null means we could not source it, not that no enum exists.
- What is a HealthKit category value enum and why does it matter?
- A category sample stores an integer, and the integer is meaningless on its own. The value enum is the type that decodes it into something like a sleep stage or a flow level. Read a category sample without knowing which enum applies and you get a number you can store, chart and completely misinterpret.
- How should a generated dataset handle facts it cannot extract?
- Write null and keep the evidence for everything it did extract. The alternative, filling gaps with plausible defaults, produces a file that looks complete and cannot be audited. A null is a visible hole that someone can go and fill. A guess is an invisible error that survives every review because nothing about it looks wrong.
Read next
- 58 HealthKit Types Apple Never Explains58 of HealthKit's 240 identifiers carry zero words of discussion prose, and the median across all 240 is 15 words. What to do when the docs say nothing.
- HealthKit's 9 Mobility Types, Barely UsedHealthKit's Mobility group is 9 identifiers the system derives from ordinary walking. No workout to start, no extra hardware, and almost nobody reads them.
- Apple's Newest HealthKit Types Are a RoadmapApple ships the data type years before most apps ship the feature. The additions since iOS 16 are a public preview of what the platform expects to matter.
Last verified . Figures come from this site’s own published datasets; see how we verify.