Skip to main content
This entry point is plain TypeScript over Uint8Array. It imports nothing from React Native, nothing from Expo, and nothing native — a lint rule fails the build if that ever changes. As a result it works in a bundler, in Node and on the web, and it is tested at 100% branch coverage rather than on a device. You can use it without the rest of the library: to build a message on a server before sending it to a phone, to parse bytes captured from a reader, or to write tests for your own tag logic.

Reading a tag

Every decodeXxxRecord is paired with an isXxxRecord guard. Calling a decoder on the wrong record type throws invalidArgument rather than returning garbage, so the guard is not optional politeness — it is how you avoid the throw.

Writing a tag

Encoding an empty list produces the canonical empty message — a single record with TNF 0x00 — which is what erasing a tag writes. Producing zero bytes instead would leave whatever was previously on the tag partially readable.

Record types

Which one to use for your own data

Use an external record. Its type name is namespaced by a domain you control, so it cannot collide with another app’s records the way a bare MIME type can:
An absolute URI record keeps the URI in the record’s type field rather than the payload. This surprises almost everyone. For an ordinary link, use the well-known URI record (createUriRecord) instead.

URI prefix abbreviation

A URI record stores a one-byte prefix identifier plus the remainder, saving 7–12 bytes. That is a meaningful fraction of a small tag: an NTAG213 has 144 usable bytes. The longest matching prefix always wins:
Disable it only for a reader known to mishandle the table:
Reserved prefix identifiers expand to nothing rather than throwing, so a tag written against a later revision of the table still yields a usable URI.

Text records and UTF-16

A text record’s status byte selects the encoding. Bit 7 means UTF-16, and plenty of Windows and Java writers set it:
Writing UTF-16 is supported but rarely worth it; UTF-8 is smaller and more widely handled.
The status byte’s bit 6 is reserved and the specification says it must be zero. This library never sets it when writing, and ignores it when reading — some writers set it, and refusing to read those tags would be worse than tolerating a bit that carries no meaning.

Smart Posters

A Smart Poster’s payload is itself a complete NDEF message, with its own record framing. That is why a decoder built only for flat records mangles them.
Inner records the decoder does not recognise are returned in unknown rather than dropped, so a lossy read never looks like a complete one.

Chunked records

A record can set the Chunk Flag to continue its payload in the records that follow. decodeMessage reassembles those transparently: you get one record with the full payload, and never see CF.
This matters more than it sounds. A decoder that ignores the flag does not fail loudly — it returns a truncated payload that looks plausible.
The encoder deliberately never emits chunks. Chunking exists so a writer can stream a payload whose length it does not yet know, which never applies once the payload is a Uint8Array in memory.

Tag-level framing

The NDEF message is usually not the first thing in tag memory.

Type 2 tags (NTAG, MIFARE Ultralight)

The message sits inside a TLV block, alongside lock and memory control blocks the manufacturer wrote when the tag was formatted. Feeding a raw data area to decodeMessage fails, because the first byte is a TLV tag, not a record header.
findNdefMessageTlv returns undefined when the tag is formatted but holds no NDEF block — deliberately distinct from holding an empty message, because you may want to write rather than report a read failure.

Type 4 tags (DESFire, Java Card applets)

The message lives in its own elementary file, and the capability container says which file, how large it may grow, and whether it is readable and writable.
writable being false covers two different situations, and the difference matters when you are deciding what to tell the user:

Byte helpers

fromHex tolerates spaces, colons and dashes because that is how AIDs and keys appear in datasheets. UTF-8 and UTF-16 are implemented here rather than delegating to TextEncoder/TextDecoder, so no polyfill is ever required on any engine. UTF-8 decoding is strict: overlong forms, encoded surrogates and out-of-range code points are rejected rather than replaced with U+FFFD, because for tag data a replacement character is a silent corruption you discover much later.

Errors

Everything throws NfcError with a machine-readable code:
Malformed-input errors name the offset and the field, so a bug report says which byte was missing rather than just “invalid tag”. Use NfcError.is() rather than instanceof: it brands on the error’s name, so it keeps working when two copies of the package end up in one bundle. Every code is in the error reference.

Strictness, and where it is deliberately relaxed

Decoding is strict where leniency would hide corruption, and lenient where strictness would make valid tags unreadable. Rejected:
  • A message with no Message Begin or no Message End flag
  • Trailing bytes after the last record
  • A chunk chain that never closes, or a continuation carrying a type or ID
  • A payload length that runs past the available bytes
  • Overlong UTF-8, encoded surrogates, odd-length UTF-16
  • A TLV using the 3-byte length form for a length that fits in one byte
  • A capability container whose declared length exceeds what was read
Accepted:
  • Zero bytes, decoded as an empty message — what a formatted but never-written tag reads back as
  • A Type 2 data area that ends with no terminator TLV — a tag whose data area is exactly full has no room for one
  • A text record with the reserved status bit set
  • Reserved URI prefix identifiers and unknown TNF values
  • Unrecognised records inside a Smart Poster, returned rather than dropped

Next

Tags

readNdef, writeNdef, getNdefStatus and formatting.

Tag protocols

Below NDEF: ISO 7816, ISO 15693, FeliCa, NTAG.