Recipe: Translate Workout Types Across HealthKit and Android
Last verified November 5, 2026 · 6 min read
Covered here:Apple HealthKitHealth Connect
A workout recorded on an iPhone and read on Android crosses three enumerations that were never designed together. The site's workout types reference lists every constant on all four platforms, read up to October 18, 2026, and joins them by one rule. This recipe is that rule as code, plus the part a lookup table cannot give you: what to write when there is no match.
The one rule#
Two constants share a row only when their names are identical after removing a leading EXERCISE_TYPE_ (Health Connect's prefix), lowercasing, and removing every character that is not a letter or a digit. HKWorkoutActivityType.running, EXERCISE_TYPE_RUNNING, Wear OS RUNNING and the Google Health API's RUNNING match; nothing is joined by meaning.
Each platform's catch-all, in its own words#
| Platform | Catch-all | What its reference says |
|---|---|---|
| HealthKit | other | "a workout that does not match any of the other workout activity types" |
| Health Connect | EXERCISE_TYPE_OTHER_WORKOUT | "Any unknown new value definition will also fall automatically into" it |
| Wear OS | UNKNOWN | fromId returns it when an id "doesn't map to an particular ExerciseType" |
| Google Health API | OTHER | for OTHER, displayName "can contain the user's custom, free-form display name" |
What the recipe does#
- Indexes the site's rows (
{ platform, constant }) from the workout-types dataset or/api/v1, preferring a current constant when a deprecated one shares its key. - Translates to a same-name constant, or to the target's catch-all with
originalset. - Reproduces the dataset's derived columns:
crosswalkKeyandsameNameOncome out identical for every row, which is what the tests check. - Reports coverage between any two platforms, so you know how much of your users' data will land on a catch-all.
- Says where the original name goes:
displayNameon the Google Health API, a title elsewhere.
The implementation#
The canonical file is cookbook/workout-type-crosswalk.mjs in this site's repository, and cookbook/workout-type-crosswalk.test.mjs runs against it on every CI run. The listing below is a verbatim copy of that file. It has no runtime dependencies and runs in Node 20 or newer or in a browser; the table is data you load, so a new platform release is a dataset refresh, not a code change.
/**
* workout-type-crosswalk.mjs — translate a workout type between HealthKit,
* Health Connect, Wear OS Health Services and the Google Health API by name
* identity only, and land everything else on each platform's documented
* catch-all with the original name kept.
*
* WHAT THIS IMPLEMENTS
* `crosswalkKey(name)` is the site's one normalization, stated on
* /workout-types: remove a leading EXERCISE_TYPE_, lowercase, drop every
* character that is not a letter or a digit. Two constants match only when
* their keys are equal. Nothing is joined by meaning: "traditional strength
* training" and "STRENGTH_TRAINING" do not match, because deciding that
* they are the same activity is a product judgment, not a lookup.
*
* `createCrosswalk(rows)` indexes rows shaped like the site's dataset
* (`{ platform, constant }`, from /datasets/workout-types-crosswalk-2026.json
* or /api/v1). `translate(from, constant, to)` returns
* { constant, matched: true } a same-name constant
* { constant: FALLBACK[to], matched: false, original } otherwise
*
* The fallbacks are what each platform's own reference documents:
* healthkit other — "a workout that does not match any of the
* other workout activity types"
* health-connect EXERCISE_TYPE_OTHER_WORKOUT — "Any unknown new value
* definition will also fall automatically into" it
* wear-os UNKNOWN — what fromId returns for an unmapped id
* google-health-api OTHER — the one type whose displayName keeps "the
* user's custom, free-form display name"
* So `original` is not decoration: on the Google Health API it is the
* displayName to send, and everywhere else it is what you store beside the
* catch-all so the user's "Padel" does not come back as "Other".
*
* PAGES THIS SUPPORTS
* https://aifitnessapi.com/workout-types
* https://aifitnessapi.com/datasets (workout-types-crosswalk-2026)
*
* No dependencies. Node 20+ or any browser.
*
* MIT — from aifitnessapi.com/cookbook
*/
export const PLATFORMS = Object.freeze(["healthkit", "health-connect", "wear-os", "google-health-api"]);
export const FALLBACK = Object.freeze({
healthkit: "other",
"health-connect": "EXERCISE_TYPE_OTHER_WORKOUT",
"wear-os": "UNKNOWN",
"google-health-api": "OTHER",
});
/** The site's normalization: strip EXERCISE_TYPE_, lowercase, keep a-z0-9. */
export function crosswalkKey(name) {
return String(name)
.replace(/^EXERCISE_TYPE_/, "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
}
/**
* @param {{ platform: string, constant: string, deprecated?: string|null }[]} rows
* @param {{ skipDeprecated?: boolean }} [opts] prefer a current constant when a key has two
*/
export function createCrosswalk(rows, { skipDeprecated = true } = {}) {
/** platform → key → constants[] */
const index = new Map(PLATFORMS.map((p) => [p, new Map()]));
const known = new Map(PLATFORMS.map((p) => [p, new Set()]));
for (const r of rows) {
if (!index.has(r.platform)) continue;
known.get(r.platform).add(r.constant);
const key = crosswalkKey(r.constant);
const bucket = index.get(r.platform).get(key) ?? [];
bucket.push(r);
index.get(r.platform).set(key, bucket);
}
function pick(bucket) {
if (!bucket?.length) return null;
const current = skipDeprecated ? bucket.filter((r) => r.deprecated !== "yes") : bucket;
return (current[0] ?? bucket[0]).constant;
}
/** Every constant on other platforms with the same key, like the dataset's sameNameOn. */
function sameNameOn(platform, constant) {
const key = crosswalkKey(constant);
const out = [];
for (const p of PLATFORMS) {
if (p === platform) continue;
for (const r of index.get(p).get(key) ?? []) out.push(`${p}:${r.constant}`);
}
return out;
}
function translate(from, constant, to) {
if (!index.has(from) || !index.has(to)) throw new RangeError(`platforms are ${PLATFORMS.join(", ")}`);
if (!known.get(from).has(constant)) throw new RangeError(`${constant} is not a ${from} constant in this table`);
const hit = pick(index.get(to).get(crosswalkKey(constant)));
if (hit) return { constant: hit, matched: true };
return { constant: FALLBACK[to], matched: false, original: constant };
}
/** How many constants of `from` have a same-name partner on `to`. */
function coverage(from, to) {
let matched = 0;
for (const c of known.get(from)) if (index.get(to).has(crosswalkKey(c))) matched++;
return { matched, total: known.get(from).size };
}
return { translate, sameNameOn, coverage };
}
/**
* The payload fields to write when a translation fell back, so the user's
* own label survives. Google Health API: displayName is honored only for
* OTHER. Elsewhere: a title or metadata field of your choosing.
*/
export function fallbackLabel(result, to) {
if (result.matched) return null;
return to === "google-health-api" ? { displayName: result.original } : { title: result.original };
}
How to adapt it#
Load the table at build time. Read workout-types-crosswalk-2026.json from the site's datasets (CC BY 4.0) into your app, and refresh it when the dataset's version changes.
Add judgment as overrides, outside the table. If your product treats two differently named types as one, check an explicit override map before calling translate, and keep that map in code review.
Store the original type. Keep the source platform and constant on every imported workout, so a better mapping later can be applied to history.
The contract the tests hold it to#
Against the published dataset: rows exist for all four platforms; crosswalkKey reproduces the dataset's key and sameNameOn reproduces its column for every row; same-name pairs translate in every direction; a HealthKit type with no Health Connect namesake lands on EXERCISE_TYPE_OTHER_WORKOUT with its name kept; every fallback is a real constant on its platform; each fallback's reason sentence is present in the site's generated copy of the vendor reference; coverage matches the dataset's counts; unknown platforms and constants throw.
What it deliberately does not do#
It does not map by meaning, it does not translate segment types or per-type metrics, and it does not decide what a catch-all workout should count toward in your product.
Frequently asked questions
Why not map workout types between platforms by meaning instead of by name?
Because a meaning match is a product decision presented as a lookup. Whether HealthKit's traditionalStrengthTraining and a platform's STRENGTH_TRAINING are the same activity depends on what your app does with the record: calorie models, segment types and coaching rules all differ by type, and the platforms do not publish a mapping that settles it. Name identity is reproducible and needs no judgment, so the recipe uses only that and shows you, through coverage(), how much it leaves unmatched. Where you do decide two types are equivalent, add that pair as an explicit override in your own code, where it can be reviewed.
What happens to an unknown exercise type value written to Health Connect?
Google's ExerciseSessionRecord reference says any unknown new value definition will also fall automatically into EXERCISE_TYPE_OTHER_WORKOUT, and the androidx source converting platform values to SDK values falls back to the same constant. So an app that writes a type an older reader does not know will be read as a generic workout, not rejected. That is why the recipe returns the original name with every fallback: stored beside EXERCISE_TYPE_OTHER_WORKOUT, as a session title for example, it lets the user's Padel come back as Padel rather than as Other workout.
Can a custom workout name be kept when writing to the Google Health API?
Only on the OTHER exercise type. The Google Health API's displayName field is required, but its v4 reference says that for every type other than OTHER the system ignores client input and generates the name from the exercise type, while for OTHER it can contain the user's custom, free-form display name. So when a translation falls back to OTHER, send the original name as displayName; when it matched a specific type, any name you send is replaced. The recipe's fallbackLabel returns a displayName for the Google Health API and a title for the other platforms.
Keep reading
Elsewhere on the site
Pages that share this one’s concepts and sources, from other sections.
Next steps
Was this page useful?
Corrections that change a page are logged, and credited by name if you ask: contributors.
Cite this page
- Title
- Recipe: Translate Workout Types Across HealthKit and Android
- Publisher
- AIFitnessAPI
- URL
- https://aifitnessapi.com/cookbook/workout-type-crosswalk
- Last verified
AIFitnessAPI. "Recipe: Translate Workout Types Across HealthKit and Android." Last verified 2026-11-05. https://aifitnessapi.com/cookbook/workout-type-crosswalk
@misc{aifitnessapi-cookbook-workout-type-crosswalk,
author = {{AIFitnessAPI}},
title = {Recipe: Translate Workout Types Across HealthKit and Android},
year = {2026},
url = {https://aifitnessapi.com/cookbook/workout-type-crosswalk},
urldate = {2026-11-05},
note = {Last verified 2026-11-05}
}Cite the page, not the site — every page carries its own verification date.
Independent comparison, last reviewed November 5, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.
← All cookbook · by AIFitnessAPI