npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@1gr14/flat

v0.1.4

Published

Flatten and unflatten nested objects, and convert them to and from URL query strings — arrays, deep nesting, and custom value encoding.

Downloads

2,194

Readme

@1gr14/flat

Flatten a nested object into flat key → value pairs and back — the one piece of code behind both URL query strings and multipart FormData. Arrays, deep nesting, files, and custom encoding included.

CI npm coverage gzip license

URL query strings and multipart FormData look like two different problems, but underneath they're the same one: each can only carry a flat list of key → value pairs. To push a nested object through either, you flatten it to bracket-notation keys on one side and rebuild it on the other — and that work is byte-for-byte identical whether the destination is a URL or a FormData.

qs does the query-string half well, but it's heavier and aimed only at search params. Building the Point0 framework, I needed to flatten any object for both a search string and a FormData body — the same code, two transports — so it belongs in one small package. That's flat.

Two pairs of functions, one for each transport. serialize / deserialize give you the flat bracket-notation map (what you feed to FormData); stringify / parse add URL encoding on top and hand you a query string. Arrays, deep nesting, and repeated keys just work; File / Blob values ride along inside FormData untouched; and prototype-polluting keys are dropped.

import { serialize, deserialize, stringify, parse } from '@1gr14/flat'

const obj = { q: 'shoes', filters: { price: { min: 10 } }, tags: ['a', 'b'] }

// nested object ⇄ flat bracket-notation map (what you append to FormData)
const serialized = serialize(obj)
// { q: 'shoes', 'filters[price][min]': 10, 'tags[0]': 'a', 'tags[1]': 'b' }  — types kept
const deserialized = deserialize(serialized)
// { q: 'shoes', filters: { price: { min: 10 } }, tags: ['a', 'b'] }

// nested object ⇄ URL query string
const stringified = stringify(obj)
// 'q=shoes&filters[price][min]=10&tags[0]=a&tags[1]=b'  (brackets URL-encoded)
const parsed = parse(stringified)
// { q: 'shoes', filters: { price: { min: '10' } }, tags: ['a', 'b'] }  — all strings

All four are also bundled on a flat namespace (flat.serialize, …), which is the package's default export — handy if you'd rather not name every import.

Install

bun add @1gr14/flat
# or: npm install / pnpm add / yarn add

Bun 1+ or Node.js 20+. ESM only.

Query strings: stringify and parse

stringify turns a nested object into a query string; parse turns it back. Arrays and nested objects round-trip, and repeated keys collapse into an array:

stringify({ x: '1', deep: { y: 2 }, list: ['a', 'b'] })
// 'x=1&deep[y]=2&list[0]=a&list[1]=b'  (brackets URL-encoded)

parse('x=1&deep[y]=2&list[0]=a&list[1]=b')
// { x: '1', deep: { y: '2' }, list: ['a', 'b'] }

parse('a=1&a=2') // { a: ['1', '2'] }  — repeated keys → array

parse accepts a string with or without a leading ?, decodes percent-escapes, and reads + as a space. Every parsed value is a string — query strings carry no types — so coerce on your side, or use fromPrimitiveString (below).

Flatten: serialize and deserialize

Need the flat key/value map instead of a string? serialize flattens a nested object to bracket-notation keys; deserialize rebuilds it. Unlike a query string, this keeps the original leaf values as-is — numbers stay numbers, and a File stays a File:

serialize({ x: 1, user: { profile: { name: 'john' } }, z: ['a', 'b'] })
// { x: 1, 'user[profile][name]': 'john', 'z[0]': 'a', 'z[1]': 'b' }

deserialize({ 'user[profile][name]': 'john', 'z[0]': 'a', 'z[1]': 'b' })
// { user: { profile: { name: 'john' } }, z: ['a', 'b'] }

deserialize drops the prototype-polluting keys __proto__, prototype, and constructor, so it's safe to run on untrusted input. (serialize skips them too.)

FormData

FormData is flat as well — and unlike a query string it can carry files. serialize flattens your object while keeping File / Blob values intact, so you append each entry as-is; on the server, read the entries back and deserialize:

// client — nested object (with a file) → FormData
const flat = serialize({ user: { name: 'Ada' }, avatar: fileFromInput })
const body = new FormData()
for (const [key, value] of Object.entries(flat)) {
  for (const item of Array.isArray(value) ? value : [value]) {
    body.append(key, item) // 'user[name]' → 'Ada', 'avatar' → the File
  }
}

// server — FormData → nested object, file and all
const flatEntries = Object.fromEntries(body.entries())
deserialize(flatEntries) // { user: { name: 'Ada' }, avatar: File }

Pair it with a serializer (Dates, numbers, ...)

There's a catch: FormData turns every non-Blob value into a string via String(value), so a Date, number, or boolean won't survive the trip on its own. The fix is the serializer you're already using (superjson, or your own): let it encode each leaf, and let flat handle the structure — files stay as Blobs.

import { serialize, deserialize } from '@1gr14/flat'
import superjson from 'superjson'

// client — encode each non-file leaf
const flat = serialize({
  user: { name: 'Ada', since: new Date() },
  avatar: file,
})
const body = new FormData()
for (const [key, value] of Object.entries(flat)) {
  for (const item of Array.isArray(value) ? value : [value]) {
    body.append(key, item instanceof Blob ? item : superjson.stringify(item))
  }
}

// server — decode each non-file leaf, then rebuild the object
const flatEntries = Object.fromEntries(
  [...body.entries()].map(([key, value]) => [
    key,
    value instanceof Blob ? value : superjson.parse(value),
  ]),
)
deserialize(flatEntries) // { user: { name: 'Ada', since: Date }, avatar: File }

This is exactly what Point0 does: run the body through its serializer, flatten with flat, then append — files as Blobs, everything else encoded.

Array keys: arrayIndexes

By default arrays use numeric indexes (tags[0]), which round-trip in order. Pass arrayIndexes: false for empty brackets (tags[]) instead — the form many backends and HTML forms expect:

serialize({ tags: ['x', 'y'] }) // { 'tags[0]': 'x', 'tags[1]': 'y' }
serialize({ tags: ['x', 'y'] }, { arrayIndexes: false }) // { 'tags[]': ['x', 'y'] }

stringify takes the same option, since it flattens through serialize first.

Custom value encoding: toPrimitiveString / fromPrimitiveString

stringify writes each leaf with toPrimitiveString, and you can override it to control exactly how values are written — return undefined to drop a key entirely. parse takes the inverse, fromPrimitiveString, to post-process each decoded value:

stringify(
  { id: 7, enabled: true, secret: 'skip-me' },
  {
    encode: false,
    toPrimitiveString: (value) =>
      value === 'skip-me' ? undefined : `v:${value}`,
  },
)
// 'id=v:7&enabled=v:true'  — `secret` dropped

The default toPrimitiveString is exported too, so you can wrap it instead of reimplementing it: it stringifies numbers/booleans/bigints, JSON.stringifys objects, and drops null / undefined / blank strings.

Unencoded output: encode

By default stringify percent-encodes keys and values. Pass encode: false for a human-readable query string — handy for prettier URLs. Note: unencoded output can be ambiguous when keys or values contain &, =, or ?.

stringify({ user: { name: 'Ada' } }) // 'user%5Bname%5D=Ada'
stringify({ user: { name: 'Ada' } }, { encode: false }) // 'user[name]=Ada'

Depth limit: maxDepth

Every function takes maxDepth (default 64). Paths deeper than the limit stay flat instead of nesting — a guard against pathological input:

stringify({ a: { b: { c: 1 } } }, { maxDepth: 2, encode: false }) // 'a[b]={"c":1}'
deserialize({ 'a[b][c]': '1' }, { maxDepth: 2 }) // { 'a[b][c]': '1' }  — kept flat

Requirements

  • Bun 1+ or Node.js 20+ (ESM only)
  • TypeScript 5+ (optional — works in plain JS too)

Community

Questions, bugs, or want to hang with other builders? Join the 1gr14 community — one hub for all our open-source projects, this one included. Get help, share what you built, or just say hi: 1gr14.dev/#community

Contributing

Issues and PRs welcome. See CONTRIBUTING.md and the Code of Conduct. Commits follow Conventional Commits. Security reports: SECURITY.md.

License

MIT


Made by 1gr14, driven by community