Parse and encode FIT files in JavaScript and TypeScript. The parser supports files produced by Garmin, Polar, Suunto, and other FIT-compatible devices, including developer-defined data.
- Parse Node.js
Bufferand standardArrayBufferinputs. - Choose flat lists, nested activity data, or both output shapes.
- Convert speed, length, temperature, and pressure fields to preferred units.
- Decode developer fields while preserving record alignment when descriptions arrive after their definitions.
- Optionally preserve every unmapped native or unresolved developer field in
unmapped_messageswith its exact wire bytes. - Encode profile-agnostic FIT messages with validated field definitions and CRCs.
- Use ESM or CommonJS with bundled TypeScript declarations.
- Node.js 20 or newer
npm install fit-file-parserThe static profile contains 126 messages, 1,444 fields, 200 types, and 4,403
mapped values. Pass includeUnmappedMessages: true to retain fields outside
that maintained surface by number and exact bytes; applications that need all
bytes for recognized messages can also opt into raw_messages.
The Promise API is the simplest way to parse a file:
import { readFile } from 'node:fs/promises'
import FitParser from 'fit-file-parser'
const content = await readFile('./activity.fit')
const parser = new FitParser({
mode: 'list',
speedUnit: 'km/h',
lengthUnit: 'km',
})
const data = await parser.parseAsync(content)
console.log({
sessions: data.sessions?.length ?? 0,
laps: data.laps?.length ?? 0,
records: data.records?.length ?? 0,
})import { readFile } from 'node:fs'
import FitParser from 'fit-file-parser'
readFile('./activity.fit', (readError, content) => {
if (readError) {
console.error(readError)
return
}
const parser = new FitParser()
parser.parse(content, (parseError, data) => {
if (parseError) {
console.error(parseError)
return
}
console.log(data)
})
})Parser errors are strings. parseAsync() rejects with the same value that the
callback API receives as its first argument.
All options are optional.
| Option | Values | Default | Behavior |
|---|---|---|---|
mode |
list, cascade, both |
list |
Controls whether primary activity collections are returned as root lists, nested data, or both. |
force |
true, false |
true |
Skips header and file CRC validation and enables supported best-effort field recovery. Structural header and data bounds are always validated. |
speedUnit |
m/s, km/h, mph |
m/s |
Converts speed-related fields. |
lengthUnit |
m, km, mi |
m |
Converts distance, altitude, and other length-related fields. |
temperatureUnit |
celsius, °C, kelvin, fahrenheit |
celsius |
Converts temperature fields. °C remains available as a legacy alias. |
pressureUnit |
bar, cbar, psi |
bar |
Converts pressure and tank-pressure fields. |
elapsedRecordField |
true, false |
false |
Adds elapsed_time and timer_time, in seconds, to records. |
includeRawDeveloperFields |
true, false, message-number array |
false |
Adds a lossless, message-associated view of developer-field bytes at raw_developer_fields. |
includeRawMessages |
true, false, message-number array |
false |
Adds selected FIT messages with exact native/developer bytes and wire metadata at raw_messages. |
rawMessagesOnly |
true, false |
false |
Omits decoded activity collections when a consumer needs only the opt-in raw-message representation. |
force: true does not make arbitrary bytes a valid FIT file. Inputs that are
too short, have an invalid header size or signature, or declare data beyond the
available bytes are rejected in both modes.
By default, developer fields retain the existing decoded, name-keyed output.
Set includeRawDeveloperFields: true when a consumer also needs exact field
bytes or must distinguish same-named fields from different developer-data
indexes:
const data = await new FitParser({
force: false,
includeRawDeveloperFields: [18],
}).parseAsync(content)
for (const field of data.raw_developer_fields ?? []) {
console.log({
globalMessageNumber: field.global_message_number,
messageIndex: field.message_index,
developerDataIndex: field.developer_data_index,
fieldDefinitionNumber: field.field_definition_number,
rawBytes: field.raw_value,
})
}message_index is the zero-based occurrence of that global message number in
file order. raw_value is a defensive plain-number copy of the exact FIT field
bytes, including interior NUL bytes and invalid sentinels. Join
developer_data_index and field_definition_number to the existing
developer_data_ids and field_descriptions collections to interpret an
application and field. The parser deliberately does not infer application- or
provider-specific semantics. Pass true to retain developer fields from every
global message, or an array such as [18] to bound collection to specific FIT
message numbers.
Set includeUnmappedMessages: true to return fields that are absent from the
maintained profile or belong to an unknown global message in
unmapped_messages. Only unmapped fields are retained, so normal known fields
are not duplicated:
const data = await new FitParser({ includeUnmappedMessages: true })
.parseAsync(content)
for (const message of data.unmapped_messages ?? []) {
console.log({
globalMessageNumber: message.global_message_number,
messageIndex: message.message_index,
fields: message.fields,
developerFields: message.developer_fields,
})
}Each entry uses the same wire-level field representation as raw_messages.
This opt-in ensures a newer FIT field cannot disappear merely because it
does not yet have a reviewed semantic mapping.
Set includeRawMessages when a consumer needs native FIT numeric codes rather
than the parser's intentionally formatted enum names, or needs native and
developer fields grouped exactly by message occurrence:
const data = await new FitParser({
force: false,
includeRawMessages: [18, 26, 72, 206, 207],
rawMessagesOnly: true,
}).parseAsync(content)
for (const message of data.raw_messages ?? []) {
console.log({
globalMessageNumber: message.global_message_number,
messageIndex: message.message_index,
littleEndian: message.little_endian,
fields: message.fields,
developerFields: message.developer_fields,
})
}Each native field includes its field-definition number, wire base-type byte,
and a defensive plain-number copy of its exact bytes. Developer fields retain
their developer-data index, field-definition number, and exact bytes. Empty
selected messages are retained with empty field arrays. For a compressed
timestamp record, the timestamp is not present on the wire and therefore is
not invented in fields; its reconstructed native FIT timestamp is exposed as
compressed_timestamp instead.
The parser does not apply profile enum formatting, scaling, invalid-sentinel
filtering, or provider semantics to this opt-in representation. Its normal
decoded output remains unchanged. Pass true to retain every message or a
global-message-number array to keep memory use bounded. raw_messages and
raw_developer_fields are independent opt-ins; enabling raw_messages does
not add the flattened raw_developer_fields property. Set rawMessagesOnly
when only this representation is needed; the parser still validates and walks
the complete FIT data section but does not retain decoded activity collections.
The mode controls where sessions, laps, records, and related activity collections are exposed.
| Mode | Root lists | Nested under activity |
Default |
|---|---|---|---|
list |
Yes | No | Yes |
cascade |
No | Yes | No |
both |
Yes | Yes | No |
In cascade output, sessions contain their laps and laps contain their records and lengths. Other parsed FIT message collections remain available where the parser exposes them.
Every recognized message is also retained in file order in data.messages.
This additive index is useful for message kinds that historically exposed
only the last value at the root:
const workoutSteps = data.messages?.workout_step ?? []
const diveSummaries = data.messages?.dive_summary ?? []Existing root lists, cascade nesting, and last-message root properties remain unchanged.
Recognized message names, field names, enum values, wire types, scales,
offsets, arrays, and units come from the community-maintained profile in
src/profile.ts and src/profile-lookup-data.ts. Its maintenance and
verification contract is documented in
PROFILE.md.
Public names use snake_case while preserving established alphanumeric tokens
such as n2, po2, and time128. Unmapped fields remain available by number
and exact bytes in unmapped_messages rather than receiving guessed names.
Only values present in the FIT input are emitted. In particular,
product_name is not inferred from manufacturer and product, and record
elapsed_time and timer_time are added only when elapsedRecordField: true
is requested. Parsed FIT timestamps are Date objects, FIT bool fields keep
their numeric wire values, mask fields decode to { value, ...flags } objects,
unknown enum IDs remain numbers, and invalid entries retained inside FIT arrays
are null. All profile fields are optional because each FIT message definition
chooses which fields are present.
Applications that need profile lookups independently of parsing can use the exported manufacturer, Garmin product, sport, sub-sport, and course-point helpers:
import {
getFitCoursePointId,
getFitGarminProductDisplayName,
getFitManufacturerName,
getFitSportId,
getFitSportName,
getFitSubSportId,
getFitSubSportName,
} from 'fit-file-parser'
getFitManufacturerName(1) // "garmin"
getFitGarminProductDisplayName(4655) // "Edge MTB"
getFitSportName(2) // "cycling"
getFitSubSportName(153) // "mountain_enduro"
getFitSportId('cycling') // 2
getFitSubSportId('indoor_cycling') // 6
getFitCoursePointId('rest_area') // 29These helpers read the same maintained profile used by the decoder. The product
display helper is opt-in and does not synthesize or overwrite parsed
product_name values.
Lookup-only consumers can import the same helpers from
fit-file-parser/profile without loading the parser entry point.
Consumers that need strict FIT framing, CRC validation, compressed timestamps, and exact fields without loading the semantic profile can use the lightweight raw entry point:
import { readFitMessages } from 'fit-file-parser/raw'
const { messages } = readFitMessages(content, {
messageNumbers: [18, 26, 72],
maxInputBytes: 64 * 1024 * 1024,
})The result retains native and developer fields as defensive Uint8Array
copies. It does not apply names, enum formatting, scales, units, or
provider-specific interpretation. Invalid input throws FitMessageReaderError
with a stable code such as invalid_header, invalid_crc, or
invalid_structure.
Both parser methods accept:
- Node.js
Buffer ArrayBuffer
const parsed = await new FitParser().parseAsync(arrayBuffer)FIT producers may define custom fields outside the standard profile. The parser consumes every developer field's declared byte size so later messages stay aligned. If a field description is not available yet, that value is omitted. Subsequent values are decoded by name once the description appears.
FitEncoder writes FIT headers, message definitions, data messages, and CRCs.
It is profile-agnostic: callers provide profile message and field numbers,
base types, sizes, and values in their raw FIT representation. Applying FIT
scales and offsets is the caller's responsibility.
import { FitBaseType, FitEncoder } from 'fit-file-parser/encoder'
const encoder = new FitEncoder()
encoder.writeMessage(0, [
{
number: 0,
size: 1,
baseType: FitBaseType.Enum,
value: 6,
},
{
number: 4,
size: 4,
baseType: FitBaseType.Uint32,
value: FitEncoder.toFitTimestamp(new Date()),
},
])
const fitBytes = encoder.close()writeMessage(globalMessageNumber, fields, localMessageNumber?) accepts local
message numbers from 0 through 15. Definitions are emitted automatically and
reused until the shape assigned to that local number changes. close() returns
a Uint8Array.
The encoder also provides:
FitEncoder.string(value)for null-terminated UTF-8 field bytes.FitEncoder.toFitTimestamp(value)for FIT timestamps.FitEncoder.calculateCRC(bytes)for FIT-compatible CRC calculation.
Scalar 64-bit values use bigint. Strings, numeric arrays, and other
variable-length values use exact-size Uint8Array values. Invalid field
definitions or numeric ranges throw before a partial message is written.
The encoder remains available from the package root; the /encoder entry
point avoids loading the decoder and semantic profile.
The package includes TypeScript declarations and exports
FitParserOptions, FitEncoderField, and FitEncoderOptions.
ESM:
import FitParser, { FitBaseType, FitEncoder } from 'fit-file-parser'CommonJS:
const {
default: FitParser,
FitBaseType,
FitEncoder,
} = require('fit-file-parser')Run commands from the repository root.
| Command | Purpose |
|---|---|
npm ci |
Install locked dependencies. |
npm run build |
Build ESM and CommonJS output. |
npm run clean |
Remove the generated build output. |
npm test -- --run |
Run the complete test suite once. |
npm test -- --run test/<file>.ts |
Run a focused test file. |
npm run codegen |
Regenerate public types from the static profile. |
npm run codegen:check |
Verify generated public types are current. |
npm run profile:check |
Audit profile counts, structure, and fingerprints. |
npm run corpus:check -- <path> |
Validate an external FIT corpus without file data. |
npm run lint |
Check lint and formatting rules. |
npm run fmt |
Apply the configured formatting rules. |
npm run type-check |
Run TypeScript without emitting files. |
npm run examples |
Build and regenerate checked-in example outputs. |
npm run check |
Run profile checks, lint, types, tests, and builds. |
The external corpus is not part of this repository or the npm package. For the standard contributor layout, clone it alongside this checkout, then run the aggregate-only validation command:
git clone https://github.com/ThomasKuehne/FIT-test-files.git ../FIT-test-files
npm run corpus:check -- ../FIT-test-files --allow-force-recovery
npm run corpus:check -- ../FIT-test-files --allow-force-recovery --raw-messages
npm run corpus:check -- ../FIT-test-files --allow-force-recovery --raw-messages-with-decoded-output
npm run compatibility:check -- /path/to/reference-package ../FIT-test-filesThe command accepts any corpus path; the sibling location is only a convenient
convention. Add --raw-messages to exercise lossless raw-message-only parsing
for every message in each file, or --raw-messages-with-decoded-output to also
verify the ordinary decoded output path. Add --unmapped-summary for a
privacy-safe aggregate of unknown message/field identifiers, base types, sizes,
occurrences, and file counts. The corpus contains a known header-CRC failure
that is expected to recover only in force mode. Reports never print file names
or parsed activity values.
compatibility:check parses every corpus file with both the current build and
a reference package, compares their complete default outputs, and reports only
aggregate counts. Files rejected by both strict parsers are retried in force
mode.
Edit src/profile.ts and src/profile-lookup-data.ts only through reviewed
profile changes with focused regression coverage and a documented source. Do
not edit src/fit_types.ts manually; run npm run codegen after changing the
maintained profile.
Repository-specific automation guidance is tracked in
.agent/README.md. More examples are available in the
examples directory, and release notes are in the
CHANGELOG.
This project started from work by
Pierre Jacquier. Thanks to
Mikael Lofjärd for
his early prototype, and to everyone in
CONTRIBUTORS.md.
MIT; see LICENSE.
Copyright 2019-present Dimitrios Kanellopoulos.