---
title: "One Field, Three Tries, Two Honest Nulls"
canonical: "https://aifitnessapi.com/blog/building-the-healthkit-dataset"
type: "blog-post"
published: "2026-09-01"
last_reviewed: "2026-09-01"
description: "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."
tags:
  - "healthkit"
  - "data"
  - "methodology"
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"One Field, Three Tries, Two Honest Nulls\", AIFitnessAPI, https://aifitnessapi.com/blog/building-the-healthkit-dataset"
---

# 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.

- Canonical: https://aifitnessapi.com/blog/building-the-healthkit-dataset
- Published: 2026-09-01
- Last reviewed: 2026-09-01
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "One Field, Three Tries, Two Honest Nulls", AIFitnessAPI, https://aifitnessapi.com/blog/building-the-healthkit-dataset

---

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](/healthkit-identifiers), 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](/data/heart-rate-api) 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](/healthkit-errors) — 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](/blog/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](/datasets), 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](/methodology), 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](/blog/healthkit-sum-or-average), with the reproductive-health
types in [the menstrual cycle API reference](/data/menstrual-cycle-api).

## FAQ

### 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.
