Skip to content

@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

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.

  • 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.nativeId is TS2339 until status === '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.

Outcome: A native Expo app can check HealthKit or Health Connect availability and request workout access.

Terminal window
pnpm add @gj-kit/expo-workouts

Add the config plugin and use a development build; Expo Go and the web cannot call the native module.

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;
}

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 an Expo app needs platform health data while retaining location collection, UI, and sync ownership.

Do not use it for live GPS tracking, background location policy, or server-side health-data processing.

Engines: node >=20

Peer Supported range Required for
expo >=56.0.0 <58.0.0 documented optional subpaths only
Entry Exported symbols
Root entry 136
./core 123
./plugin 1
./testing 10

This is a native module: Expo Go and web/Node are intentionally unsupported for native calls. Explain health permissions before requesting them.

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.

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.

npm · GitHub