# plist.js parses and builds Apple property lists in JavaScript

> The plist package reads and writes the three property list formats Apple uses, XML, binary bplist00 and OpenStep, auto-detecting the format from the input, and it ships a browser build that depends on nothing because it uses the native DOMParser instead.

**TooTallNate/plist.js** — Apple property list parser/builder for Node.js and browsers — supports XML, binary (bplist), and OpenStep formats

- Repository: https://github.com/TooTallNate/plist.js
- Website: https://plist.n8.io
- Stars: 606 · Forks: 126
- Language: TypeScript
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/tootallnate-plist-js

## Three formats, one API

plist.js is an Apple property list parser and builder for Node.js and browsers, and it supports the XML, binary and OpenStep formats. The feature list puts it plainly: parse XML, binary and OpenStep plists with the format auto-detected, and build XML and binary plists from JavaScript objects.

That split is the shape of the problem. The three formats are not variants of one syntax. XML plists are verbose but readable, binary plists are compact and opaque, and OpenStep is the old ASCII style that the defaults read command on macOS still reads and writes. Anything that has to deal with Apple configuration in the wild has to deal with all three, and a project that handles only the XML form will fail the first time it meets a binary profile or a defaults read dump.

The library is written in TypeScript with full type declarations, and the values it produces are described by a single recursive union type rather than by any, which is what makes the type mapping table in the documentation meaningful rather than decorative.

The audience is a developer working in JavaScript who has to deal with plist files. The package is on npm as plist, the repository lives at TooTallNate/plist.js, and there is an interactive playground at plist.n8.io for trying the parser without setting anything up.

The project's history is worth noting too, because it explains the current shape. The release list shows 3.0.2 from 2021 and then plist@5.0.0 in May 2026, so this is a long-lived package that has been substantially rewritten rather than a new arrival.

## Format detection comes from the shape of the input

The auto-detection is not a magic sniff of the content, it is a consequence of the type you pass in, and the documentation is explicit about it.

A string input is XML or OpenStep, and those two are told apart by their first character. The OpenStep format, the old-style ASCII format used by defaults read on macOS, is detected when the input starts with { or (. Everything else in string form is treated as XML.

Binary plists are detected when the input is a Uint8Array or an ArrayBuffer, and the example shows the two ways to read one:

```ts
import { readFileSync } from 'node:fs';
import { parse, parseBinary } from 'plist';

// Auto-detected from binary data
const buf = readFileSync('Info.plist');
const obj = parse(new Uint8Array(buf));

// Or use parseBinary() directly
const obj2 = parseBinary(new Uint8Array(buf));
```

There is a matching direct function for OpenStep:

```ts
import { parse, parseOpenStep } from 'plist';

// Auto-detected
const obj = parse('{ CFBundleName = "My App"; CFBundleVersion = 42; }');

// Or use parseOpenStep() directly
const obj2 = parseOpenStep('( item1, item2, item3 )');
```

The direct functions are the safer choice in code that knows what it has, because they fail loudly on the wrong format instead of guessing. The general parse function is the convenient one, and for a document whose provenance you do not control, the auto-detection is the difference between handling the file and failing on it.

## Building XML and binary output

The builder produces XML by default and binary on request, and the XML path has formatting options that matter more than they sound.

```ts
import { build } from 'plist';

const xml = build({
  CFBundleName: 'My App',
  CFBundleVersion: '1.0',
  LSRequiresIPhoneOS: true,
  UISupportedInterfaceOrientations: [
    'UIInterfaceOrientationPortrait',
  ],
});
```

The three options are opts.pretty, a boolean that defaults to true and controls whether the output is pretty-printed with indentation, opts.indent, the indentation string with a default of two spaces, and opts.newline, the newline string with a default of a line feed. The result is a string.

The binary path returns bytes rather than a string, which is the correct shape for a file on disk:

```ts
import { writeFileSync } from 'node:fs';
import { buildBinary } from 'plist';

const data = buildBinary({
  CFBundleName: 'My App',
  CFBundleVersion: '1.0',
});

writeFileSync('Info.plist', data);
```

Note what is missing from the builder. There is no OpenStep output, only XML and binary, which is a real asymmetry in an otherwise symmetric API. If your pipeline has to emit the old ASCII format, this library parses it but does not write it.

## The type mapping, and where a round trip loses information

The documentation includes a table mapping each plist type to its JavaScript equivalent, and it is short enough to reproduce:

```ts
type PlistValue =
  | string
  | number
  | boolean
  | Date
  | Uint8Array
  | PlistValue[]
  | { [key: string]: PlistValue }
  | null;
```

Strings map to string, integers and reals both map to number, the true and false tags map to boolean, dates map to Date, data maps to Uint8Array, arrays to Array and dictionaries to Object. Null is part of the union as well.

The integer and real collapse is the detail to keep in mind. A plist can distinguish an integer from a real, and JavaScript's number type cannot, so a value that was an integer on disk may come back as a number that is indistinguishable from a real. If you are reading a configuration that a tool will rewrite, that distinction can matter, and the library does not claim to preserve it.

Dates are the other case worth thinking about, because a Date survives the mapping but only because the date tag is parsed into a Date object. Everything else is a straightforward string, number, boolean or byte array, which is the good case, because those round trip exactly.

The recursive type is also what makes the library pleasant to use with TypeScript. A nested dictionary is typed, so an editor can tell you that a key you are reading is a PlistValue and force you to narrow it, rather than letting you discover at runtime that you handed a string to a code path that expected an array.

## The browser build pulls in nothing

The browser story is the most interesting engineering decision in the package, and it is a two-part trick.

The README states that the library is browser-optimized, using native DOMParser in browsers with zero dependencies, and that it is lightweight at about 4 KB gzipped in the browser. In a bundled application with Vite or webpack, you import normally, and the browser-optimized build is selected automatically through conditional exports:

```ts
import { parse, build } from 'plist';
```

The mechanism is in the package manifest. The exports map has a browser condition that resolves to a separate file, dist/index.browser.js, while the import and default conditions resolve to dist/index.js. A bundler that honours conditional exports picks the browser file, and the browser file uses the native DOMParser and string-based XML building, so @xmldom/xmldom and xmlbuilder are not included in the bundle. Those two packages remain the Node dependencies, which is where the DOM implementation is actually needed.

The trade is worth naming. The browser build is not the Node build with a shim, it is a separate implementation of the XML path, which means XML parsing and building in a browser go through the browser's own parser rather than a bundled one. That is smaller and faster to load, and it means the two environments can differ in edge cases, which is a fair price for a bundle that ships nothing.

There is an interactive playground at plist.n8.io for checking behaviour in a browser before you commit to it.

## A rewritten 5.0: ESM, pnpm, changesets

The package metadata shows a 5.0 that is a different animal from the 3.x line, and the details explain why the major version number moved.

The package is marked as an ES module with a type of module, the build is tsc, and the test script runs vitest. The engines field requires Node 18 or later. The package manager is pinned to pnpm 10.11.0, and the lock file is a pnpm lock file, with a pnpm workspace file at the root, which suggests the repository is set up as a workspace with the examples directory as a member. Releases go through changesets, with the ci:publish script running changeset publish and a prepublishOnly script that builds first, and the .changeset directory is present for pending changelog entries.

One entry in the manifest is worth a closer look. There is a pnpm overrides block that pins dompurify to at least 3.4.2. Overrides like that exist because a transitive dependency resolves to a version with a known problem, and an override forces the whole tree onto a fixed version. The fact that it is in the manifest rather than in a note means the fix survives a fresh install, and it is a good example of a small package taking supply chain hygiene seriously.

The type declarations are shipped in the same export map for every condition, so the browser and Node builds share one set of types. The files array publishes only dist, which keeps the tarball to the compiled output.

The release history is short and recent, with plist@5.0.0 on 2026-05-03 and 3.0.2 on 2021-03-26, and the last push to the repository was on 2026-09-10. The repository is not archived.

## Conclusion

plist.js fits a Node or browser project that has to read an Info.plist, a mobile configuration profile or a defaults read output, and that wants one typed API across the three formats instead of a native binary plist parser or a regex over XML. It is the wrong tool if you need to write the OpenStep format, because the builder covers XML and binary only, and it is the wrong tool if you need to preserve the exact type of every number, since integers and reals both land on the JavaScript number type. Before adopting it, read the type mapping table rather than assuming a round trip is lossless, and check the exports map, because the browser and Node builds are different files with different capabilities.

## FAQ

### What formats does the plist package support?

It parses XML, binary bplist00 and OpenStep plists, with the format auto-detected, and it builds XML and binary plists from JavaScript objects. There is no OpenStep output, only OpenStep input.

### How do I install plist.js?

Run npm install plist. The package is published as plist on npm, is MIT licensed, and works in Node 18 and later as well as in browsers, where the browser build is selected automatically through conditional exports.

### How does plist.js detect the plist format?

Binary plists are detected when the input is a Uint8Array or an ArrayBuffer, and among string inputs the OpenStep format is detected when the input starts with { or (, with everything else treated as XML. There are also direct parseBinary() and parseOpenStep() functions when the format is known.

### How do I build a binary plist with plist.js?

Use buildBinary, which takes the same object shape as build and returns a Uint8Array, so it can be written directly to a file with writeFileSync. The XML builder returns a string and accepts pretty, indent and newline options.

### Is plist.js usable in a browser without extra dependencies?

Yes. The browser build uses the native DOMParser and string-based XML building, so @xmldom/xmldom and xmlbuilder are not included in the bundle, and the browser build is about 4 KB gzipped. You import the package normally and the bundler picks the browser build through conditional exports.

## Sources

- [License: MIT](https://github.com/TooTallNate/plist.js/blob/master/LICENSE)
- [Project website](https://plist.n8.io)
- [README](https://github.com/TooTallNate/plist.js/blob/master/README.md)
- [Releases](https://github.com/TooTallNate/plist.js/releases)
- [TooTallNate/plist.js on GitHub](https://github.com/TooTallNate/plist.js)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/tootallnate-plist-js
