@gj-kit/expo-workouts
HealthKit and Health Connect workouts in Expo, where destroying stored data is a compile error.
- npm
0.1.2 - 460+ tests across unit, native and plugin
- 33 @ts-expect-error guards
- 0 runtime dependencies
- 60 XCTest + 70 Kotlin tests on shared fixtures
Keep learning
Section titled “Keep learning”Why this exists
Section titled “Why this exists”A Health Connect upsert is full-state: a saveWorkout that omits the route deletes the route already stored, and a write with a lower version returns normally with the same record id, so nothing but a read-back can tell it apart from a real write. HealthKit never reports read authorization at all, so a scope you forgot to declare leaves distanceM undefined on every workout with no error anywhere. And a sync loop that commits its cursor before the rows it just received loses those workouts permanently.
What it does about it
Section titled “What it does about it”- Route omission is a compile error — WorkoutWrite.route is a required
readonly RoutePoint[] | 'none', so omitting it is TS2741 — a Health Connect upsert without it deletes the route already stored. - Locked-device branch cannot be skipped — SaveResult is a union discriminated on status, so
saved.nativeIdis TS2339 untilstatus === 'saved'narrows away the pendingUnlock branch a locked device produces. - Missing scopes named before reading — unpopulatedWorkoutMetrics(state) returns the Workout field names, distanceM among them, whose gating read scope is denied or never requested — before you read a single workout.
- Safe to import anywhere — The node/browser condition routes to index.unsupported, whose built module graph contains no expo; in Expo Go requireOptionalNativeModule returns null, so import never throws and getAvailability() resolves unavailable.
- Sync gaps reproduce on Node — createFakeWorkouts() replaces the NativeWorkoutsModule seam rather than WorkoutsApi, so real ./core code runs under vitest for all six CursorResetReason values and a crash mid-drain.
Golden path
Section titled “Golden path”Outcome: A native Expo app can check HealthKit or Health Connect availability and request workout access.
1. Install
Section titled “1. Install”pnpm add @gj-kit/expo-workouts2. Keep the app-owned boundary explicit
Section titled “2. Keep the app-owned boundary explicit”Add the config plugin and use a development build; Expo Go and the web cannot call the native module.
3. Start with the smallest integration
Section titled “3. Start with the smallest integration”Copy this first, then replace only the app-owned values named above.
import { getAvailability, requestAuthorization } from '@gj-kit/expo-workouts';
export async function requestWorkoutAccess() { const availability = await getAvailability(); if (availability.status === 'available') { await requestAuthorization({ read: ['workouts'] }); } return availability;}What that looks like
Section titled “What that looks like”The two saveWorkout mistakes that only surface in production — a forgotten route and an unhandled locked device — are a TS2741 and a TS2339.
import { workouts } from '@gj-kit/expo-workouts';import type { WorkoutWrite } from '@gj-kit/expo-workouts/core';
export const write: WorkoutWrite = { id: 'a2f6c0b8-0d7e-4f31-9d1a-7c2b5e8f0a11', // your own idempotency key, not the platform's version: 3, // never Date.now(): a crash retry would mint a second workout kind: 'running', startMs: 1_754_000_000_000, endMs: 1_754_000_600_000, route: 'none', // delete this line -> TS2741; an Android upsert without it erases the stored route};
export async function save(): Promise<string | null> { const saved = await workouts.saveWorkout(write); // @ts-expect-error TS2339 — `nativeId` is absent on the `pendingUnlock` branch void saved.nativeId; if (saved.status === 'pendingUnlock') return null; // locked device: retry the same id + version return saved.nativeId; // narrowed past pendingUnlock, so it exists}Use it when
Section titled “Use it when”Use it when an Expo app needs platform health data while retaining location collection, UI, and sync ownership.
Do not use it when
Section titled “Do not use it when”Do not use it for live GPS tracking, background location policy, or server-side health-data processing.
Runtime and peers
Section titled “Runtime and peers”Engines: node >=20
| Peer | Supported range | Required for |
|---|---|---|
expo |
>=56.0.0 <58.0.0 |
documented optional subpaths only |
Public subpaths
Section titled “Public subpaths”| Entry | Exported symbols |
|---|---|
Root entry |
136 |
./core |
123 |
./plugin |
1 |
./testing |
10 |
Safety boundary
Section titled “Safety boundary”This is a native module: Expo Go and web/Node are intentionally unsupported for native calls. Explain health permissions before requesting them.
API reference
Section titled “API reference”complete API reference contains every public function, class, type, constant, and error contract generated from release declarations. Each item shows its import path, release signature, and package version.
Package relationships
Section titled “Package relationships”| Relationship | Package | Why combine them |
|---|---|---|
| Standalone | — | This package can be adopted without another GJ Kit package. |
| Optional companion | @gj-kit/format |
Format workout timestamps and totals with application-selected display rules. |