Flatby 1gr14
  • Меню
    • README.md
    • GitHub
    • Блог
  • 1gr14
    • Главная
    • Start0
    • Поддержать
    • Обучение
    • Группа
    • Блог
    • Автор
  • Сообщество
    • Discord
    • Telegram
  • Опенсорс
    • Point0
    • Route0
    • Error0
    • Flat
  • Аккаунт
    • Войти
    • Регистрация
Flatby 1gr14
Создаю опенсорс во славу Господа Иисуса Христа ☦️
С любовью ко всем разработчикам на свете ❤️
Условия использованияПолитика конфиденциальностиСергей Дмитриев 2026 😎

README.md

  • Раздел: Overview

On this page

  1. Install
  2. Query strings: stringify and parse
  3. Flatten: serialize and deserialize
  4. FormData
  5. Pair it with a serializer (Dates, numbers, ...)
  6. Array keys: arrayIndexes
  7. Custom value encoding: toPrimitiveString / fromPrimitiveString
  8. Unencoded output: encode
  9. Depth limit: maxDepth
  10. Requirements

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)

Enjoying Flat?

Give it a star on GitHub, ask questions, and follow what's new

Star Flat

A star on GitHub helps more developers find Flat
Star on GitHub

Start0

Flat integrated into the Start0 SaaS boilerplate — the best way to use it
Explore Start0

YouTube

Tutorials, walkthroughs, and updates
YouTube

Discord

Questions and chat in English
Join Discord

Telegram

News channel and chat in Russian
ChannelChat

Twitter

Posts and updates
Twitter