flatbuffers-format API
    Preparing search index...

    flatbuffers-format API

    flatbuffers-format

    The opinionated, zero-config formatter for FlatBuffers (.fbs) schemas — fast, byte-stable, type-safe, runs anywhere.

    npm CI License Node Types Bundle

    ▸ Try it in your browser — no install. Paste a .fbs, see canonical output as you type. API docs are on the same site.

    # Format a single file (prints to stdout)
    npx flatbuffers-format schema.fbs

    # Fix a tree in place — recurses; skips node_modules / .git / dist
    npx flatbuffers-format --write src/

    # Use as a CI check (silent / diff / version)
    npx flatbuffers-format --check src/
    npx flatbuffers-format --diff src/
    npx flatbuffers-format --version

    See docs/cookbook.md for 8 copy-paste recipes — CI gates, pre-commit hooks, programmatic Node/browser use, Prettier integration, repo migration tips.

    A FlatBuffers schema is a small declarative DSL — like Protobuf, Cap'n Proto, or Thrift. Unlike Protobuf and the rest, there's no official formatter. Schemas drift into per-author whitespace styles, code-review noise from "whitespace only" PRs accumulates, and CI has no --check-style gate. flatbuffers-format fixes that: one binary, zero config, deterministic output, exit-code semantics that drop straight into existing CI patterns.

    Opinionated for the same reason gofmt and rustfmt are opinionated — the formatter's value is in not relitigating style. Two knobs (indent width, newline) and that's it.

    • A canonical FlatBuffers schema grammar lives in grammar/FlatBuffers.g4 (~155 lines of ANTLR4 EBNF, mirrors the official grammar).
    • antlr-ng — a pure-TS reimplementation of the ANTLR tool — generates FlatBuffersLexer.ts, FlatBuffersParser.ts, and FlatBuffersListener.ts into generated/. No Java at any point.
    • src/printer.ts walks the ANTLR parse tree (SchemaContext, TableDeclContext, etc.) and emits formatted source.
    • src/trivia.ts recovers comments and blank lines from ANTLR's hidden channel via BufferedTokenStream.getHiddenTokensToLeft / Right.
    • format(source) is a fixed point: running it twice gives the same output as running it once.
    • table, struct, enum, union, rpc_service declarations
    • Namespaced type references (field: a.b.Foo;)
    • Fixed-size arrays (pts: [float:3];)
    • native_include directives
    • Keywords as field names (table T { enum: int; })
    • Object literals (the flatc text format)
    • All metadata: (deprecated), (key: "value"), etc.
    • Everything in flatbuffers' own tests/ and samples/ schemas, checked in CI: +inf / -nan defaults, the empty-vector default = [], union values with an explicit value or metadata (A = 5, f: T (id: 0)), single-quoted strings
    flatbuffers-format [options] <file-or-dir...>   # print formatted output to stdout
    flatbuffers-format --write <file-or-dir...> # rewrite files in place
    flatbuffers-format --check <file-or-dir...> # exit 1 if any file is unformatted
    flatbuffers-format fix <file-or-dir...> # alias for --write
    cat foo.fbs | flatbuffers-format - # read source from stdin

    Options:

    Flag Meaning
    -w, --write Rewrite files in place
    -c, --check Check formatting; exit 1 on diff (silent)
    -d, --diff Print unified diff for each file that would change; exit 1 if any
    --indent <n> Spaces (or tabs, with --use-tabs) per indent level (default: 2)
    --use-tabs Indent with tab characters instead of spaces
    --line-width <n> Target column for compact/wrap decisions (default: 80)
    --no-compact-single-line Disable single-line collapsing of small enum/union/single-field bodies
    --max-blank-lines <n> Max consecutive blank lines kept between decls (default: 1)
    --wrap-comments Reflow long line comments at whitespace
    --comment-width <n> Wrap column for --wrap-comments (defaults to --line-width)
    --trailing-comma <all|none> Comma after the last value of an expanded enum/union (default: all)
    --object-wrap <preserve|collapse> Keep a body expanded when the source breaks after its { (default: preserve)
    --no-bracket-spacing Print one-line bodies as {a, b} instead of { a, b }
    --end-of-line <lf|crlf|auto> Line endings; auto keeps the file's own (default: lf)
    --check-ignore-pragma Leave files whose top comment has @noformat or @noprettier
    --require-pragma Format only files whose top comment has @format or @prettier
    --unsafe Skip the checks that every comment and code token survives (see below)
    --config <path> Use this config file instead of looking one up
    --no-config Ignore config files
    --stdin-filepath <path> With -, look up the config as if stdin were this file
    --no-gitignore Don't consult .gitignore when walking directories
    -V, --version Print version and exit
    -h, --help Show help

    On a parse error, the CLI emits an IDE-link-friendly report (path:line:col: …) with a source-snippet and a caret under the offending column, so editors that auto-link compiler diagnostics (VS Code's terminal, neovim's :cw, IntelliJ's run console) jump straight to the problem.

    Commit a .flatbuffers-format.json so the CLI, CI and editor plugins all format the same way without per-developer flags or IDE settings:

    {
    "indent": 4,
    "compactSingleLine": false
    }

    Keys are the format options: indent, useTabs, lineWidth, compactSingleLine, maxBlankLines, wrapComments, commentWidth, trailingComma, objectWrap, bracketSpacing, endOfLine, checkIgnorePragma, requirePragma. Omitted keys keep their defaults.

    minVersion (for example "0.5.0") makes an older flatbuffers-format refuse the file with a message to update, instead of formatting with different output. Set it when the config uses a key a teammate's older copy may not know.

    The last six have the names, values and defaults of the Prettier options they copy, so a team used to Prettier gets what it expects.

    • Lookup: for each file, the nearest .flatbuffers-format.json in its directory or any parent, up to the filesystem root. Only that one file applies; files higher up are not merged in. For stdin, the lookup starts at --stdin-filepath if given, else the current directory.
    • Precedence: flags, then the config file, then the defaults. --write, --check and --diff all use it.
    • Validation: an unknown key or a value of the wrong type is an error naming the file and the key, and the CLI exits 2 without touching any file.

    Editor plugins can call the same lookup:

    import { resolveConfig } from "flatbuffers-format/config";

    const options = resolveConfig("/repo/schemas/monster.fbs"); // FormatOptions | null

    resolveConfig reads the filesystem, so it lives in the Node-only flatbuffers-format/config entry point; the main entry point stays browser-safe. It throws ConfigError (with .file) on an invalid config file.

    Directories are walked recursively. Inside a git repository, .gitignore is respected via git ls-files — pass --no-gitignore to disable. Outside a repo (or if git isn't in PATH), node_modules, .git, dist, build, out, .next, .turbo, .cache, .hg, and .svn are skipped automatically.

    npm i -D flatbuffers-format         # local dev dep
    npm i -g flatbuffers-format # global CLI
    npx flatbuffers-format ... # one-shot, no install

    Prebuilt single-file binaries are attached to every GitHub Release (starting with flatbuffers-format-v0.2.0). Drop one on PATH and run — the embedded Node 22 runtime is byte-glued in via Node SEA, so consumers don't need to install Node or npm:

    Platform Asset filename
    Linux x64 flatbuffers-format-linux-x64
    Linux arm64 flatbuffers-format-linux-arm64
    macOS x64 flatbuffers-format-macos-x64
    macOS arm64 flatbuffers-format-macos-arm64
    Windows x64 flatbuffers-format-windows-x64.exe
    curl -fsSL -o flatbuffers-format \
    https://github.com/emindeniz99/flatbuffers-format/releases/latest/download/flatbuffers-format-linux-x64
    chmod +x flatbuffers-format
    ./flatbuffers-format --version

    The binaries are produced by .github/workflows/flatbuffers-native-binaries.yml using scripts/build-native.mjs; run that script locally to build the binary for your own machine (npm run build:native). The macOS binaries carry an ad-hoc signature only — Gatekeeper will quarantine the file on first download until you approve it via Right-click → Open in Finder, or xattr -dr com.apple.quarantine ./flatbuffers-format.

    A portable flatbuffers-format.wasm is attached to every GitHub Release. Hand it to any WASI-compatible runtime — wasmtime, wasmer, wazero (Go), wasmtime-py, etc. — and it reads .fbs on stdin, writes formatted output on stdout. No Node, no npm, no language-specific bindings.

    curl -fsSL -o flatbuffers-format.wasm \
    https://github.com/emindeniz99/flatbuffers-format/releases/latest/download/flatbuffers-format.wasm
    wasmtime flatbuffers-format.wasm < schema.fbs > schema.formatted.fbs

    Use cases: embedding the formatter from non-Node hosts (Rust/Go/Python build tools), running in sandboxed serverless environments (Cloudflare Workers Pages, Vercel Edge), or shipping a reproducible-by-hash formatter in tooling pipelines. The .wasm is built by .github/workflows/flatbuffers-wasm-binary.yml using Javy (QuickJS-compiled-to-WASM) from scripts/build-wasm.mjs; reproduce locally with npm run build:wasm (requires javy on PATH).

    See docs/cookbook.md for copy-paste recipes (CI gates, pre-commit hooks, programmatic use, browser, Prettier integration, migration tips).

    There's no dedicated editor plugin yet. Until there is, run the CLI from your editor's "format on save" or "run command" hook.

    VS Code — install emeraldwalk.runonsave and add to .vscode/settings.json:

    {
    "emeraldwalk.runonsave": {
    "commands": [
    {
    "match": "\\.fbs$",
    "cmd": "npx flatbuffers-format --write ${file}"
    }
    ]
    }
    }

    Neovim — drop in an autocmd:

    vim.api.nvim_create_autocmd("BufWritePost", {
      pattern = "*.fbs",
      callback = function(args)
        vim.fn.jobstart({ "npx", "flatbuffers-format", "--write", args.file })
      end,
    })
    

    Pre-commit hook (with lint-staged):

    {
    "lint-staged": {
    "*.fbs": "flatbuffers-format --write"
    }
    }

    GitHub Actions — fail the build on unformatted .fbs:

    - run: npx flatbuffers-format --check .
    

    A hosted playground is available at https://emindeniz99.github.io/flatbuffers-format/playground/ — paste a schema and watch it format live, no install needed. The page also ships a curated set of corpus fixtures behind a load example dropdown, a share button (encodes the current input into the URL fragment, copies the link to the clipboard) and a copy button on the output pane.

    To run the same page locally: src/index.ts and the generated parser only depend on antlr4ng, which ships an ES-module build. After npm run build, serve the project root statically and open web/index.html:

    npx http-server -p 8080 .
    # then open http://localhost:8080/web/

    The page resolves the antlr4ng bare specifier via an import map pointing at node_modules/antlr4ng/dist/index.mjs. If you bundle for production with esbuild / Rollup / Vite, the bare specifier resolves normally and no import map is needed.

    You can also embed the formatter in your own page:

    <script type="module">
    import { format } from "flatbuffers-format";
    document.getElementById("out").textContent = format(src);
    </script>

    A // flatbuffers-format-ignore comment keeps the next top-level declaration exactly as written; Prettier's // prettier-ignore works too, and either may be followed by a reason:

    // flatbuffers-format-ignore -- aligned by hand
    enum Opcode : ubyte {
      Nop   = 0x00,
      Load  = 0x01,
    }
    

    To skip a whole file, put @noformat (or @noprettier) in a comment at its top and turn on checkIgnorePragma. requirePragma works the other way round: only files with @format (or @prettier) there are formatted.

    import { format, check, hasPragma, hasIgnorePragma, FormatError, CommentLossError, CodeChangeError, VERSION } from "flatbuffers-format";

    format(source: string, opts?: FormatOptions): string
    check(source: string, opts?: FormatOptions): boolean // true if already formatted
    hasPragma(source: string): boolean // top comment has @format / @prettier
    hasIgnorePragma(source: string): boolean // top comment has @noformat / @noprettier

    type FormatOptions = {
    indent?: number; // default 2
    useTabs?: boolean; // default false — tab char instead of spaces
    newline?: "\n" | "\r\n"; // default "\n"
    lineWidth?: number; // default 80 — used by compactSingleLine and wrapComments
    compactSingleLine?: boolean; // default true — collapse small enum/union/single-field bodies
    maxBlankLines?: number; // default 1 — cap on consecutive blank lines between decls
    wrapComments?: boolean; // default false — reflow long line comments
    commentWidth?: number; // default = lineWidth — wrap column for wrapComments
    trailingComma?: "all" | "none"; // default "all" — comma after the last expanded enum/union value
    objectWrap?: "preserve" | "collapse"; // default "preserve" — keep bodies the source broke after `{`
    bracketSpacing?: boolean; // default true — `{ a, b }` vs `{a, b}`
    endOfLine?: "lf" | "crlf" | "auto"; // default "lf" — overrides `newline`
    checkIgnorePragma?: boolean; // default false — skip files with @noformat / @noprettier
    requirePragma?: boolean; // default false — format only files with @format / @prettier
    unsafe?: boolean; // default false — skip the comment and code-token checks
    };

    Both format and check throw FormatError (with .line and .column) on invalid input.

    They throw CommentLossError if the formatted output would not contain every comment of the input, compared as a multiset of comment text (word by word for line comments when wrapComments is on). That is a formatter bug, not a problem with your schema: the check exists so a bug refuses to format instead of deleting comments. .missing lists the comments that would be dropped and .added any that would be duplicated.

    They throw CodeChangeError if the output's code tokens would differ from the input's: a formatter may change whitespace, comments' places and a comma before a }, nothing else. .index, .expected and .actual name the first differing token.

    For both, the CLI reports the file and the problem, exits 1, and leaves the file untouched (--write) or prints nothing (stdin). --unsafe (unsafe: true) skips both checks, for working around a bug in them.

    Measured against the 24-file test corpus (22.6 kB total) on a modern Linux laptop; reproduce with node scripts/bench.mjs after npm run build.

    Measurement
    In-process throughput 3,164 files/sec, ~316 µs/file
    Cold-start CLI (single small file, includes Node startup) ~164 ms median, ~155 ms min
    Browser bundle (esbuild --bundle --minify, includes antlr4ng runtime) 234 kB min · 58 kB min+gz

    What this means in practice:

    • Pre-commit hook on a repo with a few .fbs files: cost is dominated by Node startup; you're paying ~160 ms regardless of file count. Once warm in a watcher process, formatting a typical schema is sub-millisecond.
    • CI --check gate: a 100-file .fbs repo formats in well under a second of wall-clock plus Node startup. Cheap to wire into every PR.
    • Browser usage: 58 kB gzipped includes the entire ANTLR4 runtime. The hand-rolled sibling (kept in this repo as a differential oracle, not published) bundles to ~4 kB gzip if you ever need a lighter alternative — see docs/grammar-comparison.md.

    Every PR runs scripts/bench-compare.mjs against scripts/bench-baseline.json and fails if throughput regresses by more than 25% (in-process µs/file), cold-start median by more than 30%, or bundle min+gz by more than 15%. The comparison posts a Markdown table to the PR for visibility. To accept slower numbers legitimately, re-baseline locally (npm run bench -- --json --repeat 3 > scripts/bench-baseline.json) and commit the new baseline in the same PR.

    Construct Rule Configurable?
    Top-level One blank line between block declarations (table/struct/enum/union/rpc_service). Single statements collapse together. maxBlankLines (default 1)
    Fields name:Type (no space before colon, one space after type). Defaults: = value. Metadata: (key, key: value). Trailing ;. fixed by design
    Enum/union/single-field bodies Collapse to one line if the result fits in lineWidth; expand otherwise. A body with a line break after its { in the source stays expanded, like a Prettier object. Any comment inside the body, and per-value metadata, force expansion. compactSingleLine (default on), objectWrap (default preserve), lineWidth (default 80), bracketSpacing (default on)
    Enum values One per line (when expanded), comma-separated, with a comma after the last value. A one-line body never has one. trailingComma (default all)
    Union variants Same as enum values. Aliases written as Alias: Type. fixed by design
    Metadata Always inline: (deprecated, key: "x"). fixed by design
    Comments //, ///, /* */ all preserved, in order. Doc comments stay attached to the following declaration. Trailing comments stay on their owning line, after the comma for enum/union values (Red, // first). Comments before a } and in comment-only bodies stay inside the braces. A comment inside a declaration, where the output has no slot for it (between a field name and its :, inside metadata, inside an object literal), moves onto its own line above that declaration. Long // lines optionally reflowed. wrapComments (default off), commentWidth (default = lineWidth)
    Indent 2 spaces. indent (count), useTabs (char)
    Newlines LF, single trailing newline at EOF. endOfLine (default lf)

    Formatting never changes what a schema means. Apart from spacing, indentation and line breaks, the output differs from the input only in:

    • The comma after the last value of an enum or union written one value per line: added by trailingComma: "all" (the default), removed by "none". A body on one line never has one.
    • Comments the output has no slot for (between a field's name and its :, inside a metadata list) move onto their own line above the declaration, unchanged.
    • Long // comments are rewrapped, only with wrapComments on.
    • Line endings, per endOfLine.

    Three checks hold that line. format() compares every comment and code token of the output with the input and refuses to return output that loses or changes one (CommentLossError, CodeChangeError). CI compiles every corpus file and every schema in flatbuffers' own tests/ with flatc before and after formatting, with the default options and with every option changed, and requires byte-identical C++ code and binary schemas. To check your own schemas the same way:

    flatc --cpp -o before/ schema.fbs
    flatbuffers-format --write schema.fbs
    flatc --cpp -o after/ schema.fbs
    diff -r before/ after/

    flatc --conform old.fbs new.fbs answers a different question: whether new.fbs is a compatible evolution of old.fbs (no removed fields, no changed ids). A formatted schema always passes it against its own original, but it does not prove the two are the same schema; the codegen diff above does.

    The canonical FlatBuffers grammar is the EBNF at flatbuffers.dev/flatbuffers_grammar.html. This project's grammar/FlatBuffers.g4 is an ANTLR4 encoding of that EBNF, kept declaratively close to the upstream spec — the file is the auditable source of truth, not the TypeScript that consumes it.

    A sibling implementation (hand-rolled recursive-descent parser, no runtime deps) lives at ../flatbuffers-formatter-handrolled in the same monorepo. Both formatters are differential-tested: every file in test/corpus/ must produce byte-identical output from both engines, on every commit, via bash test/crosscheck.sh — currently 28/28 pass. That cross-check runs in prepublishOnly before any release, so a grammar bug in either implementation cannot ship.

    A second, independent check verifies that the corpus is built out of real FlatBuffers schemas — not just schemas our two parsers happen to agree on. Every test/corpus/*.fbs file is fed to Google's official flatc compiler via:

    npm run test:flatc-conform
    

    The script (scripts/flatc-conform.sh) runs flatc -b --schema --no-warnings on each corpus file and reports a pass/fail summary. For every file flatc accepts, it then formats the file (with the defaults, and with every option changed) and requires flatc's C++ code and binary schema to come out byte-identical; this part needs the engine built. scripts/upstream-schemas.sh <flatbuffers checkout> runs the same check over flatbuffers' own test schemas, which CI pins to the flatc release it uses. It is deliberately not part of prepublishOnly, because flatc is a system dependency (installed via apt-get install flatbuffers-compiler, brew install flatbuffers, etc.), not an npm one — wiring it into the publish chain would break maintainer machines that don't have it. The script skips with a clear warning (exit 0) when flatc isn't on PATH, so it stays friendly to fresh checkouts.

    The grammar is kept up to date with the latest flatc release (currently 25.12.x), which adds syntax not present in the Ubuntu/Debian apt package (still flatc 2.0.8 as of writing). Fixtures that exercise post-2.0.8 features — per-enum-value metadata, the (offset64) / (vector64) field attributes, and union with explicit underlying type (union W : uint8 { … }) — carry a MIN_FLATC_MAJOR entry in the conformance script, so contributors on older flatc see a clear SKIP — needs flatc >= 23.x message and the script still exits 0. To test against the dialect this formatter targets, install flatc from source or grab a prebuilt binary from google/flatbuffers/releases.

    This check is intentionally stricter than the formatter's own parser: flatc applies semantic rules (root-type required for object literals, attributes must be declared before use, fixed vector nesting, etc.) on top of the grammar. Files that pass the grammar but fail flatc are flagged for human review — they may be genuine formatter-edge-case schemas (which is fine), or they may indicate the corpus has drifted from real-world FlatBuffers (which is not).

    git clone https://github.com/emindeniz99/flatbuffers-format.git
    cd flatbuffers-format/flatbuffers-formatter
    npm install
    npm run build # antlr-ng codegen → tsc
    node dist/src/cli.js examples/sample.fbs
    npm test               # local suite (14 tests)
    npm run test:crosscheck # differential check vs hand-rolled sibling

    Both suites use the built-in node --test runner — no extra test deps.

    Releases are automated via release-please. The CHANGELOG is generated from Conventional Commits on main.

    Runtime Status Notes
    Node.js 20.x ✅ supported Minimum supported version (engines.node: ">=20"). EOL upstream since April 2026, kept for slower-moving users until 2027; verified in CI by installing the packed tarball with npm.
    Node.js 22.x / 24.x / 26.x ✅ supported Covered by the CI matrix on Linux, macOS and Windows.
    Modern browsers ✅ supported ESM-only. The core (src/index.ts) has zero Node imports; the CLI is the only Node-only entry.
    Bun ✅ supported Installs from npm via bun add flatbuffers-format; the ESM entry resolves natively.
    Deno ✅ supported Installs from npm via npm:flatbuffers-format or deno add npm:flatbuffers-format.
    Cloudflare Workers ✅ supported ESM + zero Node imports in the core.
    OS ✅ Linux / macOS / Windows All three covered in CI (node 22/24/26 × ubuntu + macos + windows).

    Standard semver. Before 1.0, the API surface is experimental: minor versions may include small breaking changes (noted in CHANGELOG.md and the release notes).

    The "public API" — the surface where the semver promise applies once we reach 1.0 — is exactly the named exports of src/index.ts (format, check, FormatError, CommentLossError, FormatOptions), of src/config.ts (resolveConfig, loadConfig, findConfigFile, ConfigError, CONFIG_FILE_NAME) and the CLI's documented flags. Everything else (internal printer/lexer/parser classes, the generated parser, the trivia helpers, the scripts/ directory) is implementation detail and may change in any release.

    No firm dates; this is a side project. The order below reflects what's likely to land next, not a commitment.

    0.1.x (current) — bugfixes, more corpus fixtures as real-world schemas surface in the wild, minor CLI ergonomics polish. No breaking changes.

    Landed during 0.1.x:

    • ✅ Sibling prettier-plugin-flatbuffers — drop into .prettierrc and .fbs files format alongside the rest of your codebase
    • ✅ Sibling vscode-flatbuffers — TextMate syntax highlighting + native format-on-save via this engine, no third-party "Run on Save" needed
    • ✅ Sibling tree-sitter-flatbuffers — incremental-parse + highlights for Neovim, Helix, Zed, GitHub.com, and any tree-sitter consumer

    0.2.x — under consideration:

    • Performance: experiment with parallel parsing for multi-file --write / --check runs
    • Real-world conformance: a curated list of public .fbs schemas fetched at CI time and validated against the formatter
    • A --validate mode that pipes through flatc (when installed) for semantic-layer checks beyond the grammar

    1.0 — when:

    • The 0.x API has been stable for two consecutive minors without changes
    • The public-API surface has had time to attract real-world feedback
    • A documented docs/migrating-to-1.0.md exists for any breaking changes between the last 0.x and 1.0

    Contributions targeting any roadmap item are welcome — open an issue first so we can discuss design before you spend time on a PR.

    See docs/architecture.md for a tour of how the parts fit together — lexer → ANTLR parser → printer, plus the trivia-attachment pipeline that makes comments round-trip cleanly.

    • antlr-ng is a pure-TypeScript reimplementation of the ANTLR tool; generated parsers use the antlr4ng runtime, which is the active TypeScript successor to the dormant antlr4ts. The whole pipeline is JS — no JVM, no *.jar, nothing to install beyond npm.
    • ANTLR routes whitespace and comments to a hidden channel. Recovering them is straightforward but deduping is tricky: a trailing comment after ; is also a leading hidden token of the next declaration. If you don't filter, comments get emitted twice. Fix in src/trivia.ts: only treat hidden tokens before the first newline as already-claimed by the previous node.
    • ANTLR's INT_LITERAL / FLOAT_LITERAL tokens are scoped per lexer rule, so you can't easily share a single NUMBER rule between them. Keeping them separate matches flatc's lexer and makes the parser rules read more naturally (('+' | '-')? INT_LITERAL).
    • The grammar uses inline string literals ('table', 'struct', ':', …) so ANTLR generates anonymous T__0, T__1, … tokens. That's fine for parsing but means the generated .tokens file isn't human-readable — a named lexer pass (TABLE: 'table';) would be the polished move if you ever need cross-language reuse via the .g4.