The opinionated, zero-config formatter for FlatBuffers (.fbs) schemas — fast, byte-stable, type-safe, runs anywhere.
▸ 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.
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 declarationsfield: a.b.Foo;)pts: [float:3];)native_include directivestable T { enum: int; })flatc text format)(deprecated), (key: "value"), etc.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 stringsflatbuffers-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.
.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.--write, --check and --diff all use it.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:
.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.--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.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:
trailingComma: "all" (the default), removed
by "none". A body on one line never has one.:, inside a metadata list) move onto their own line above the
declaration, unchanged.// comments are rewrapped, only with wrapComments on.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.
flatcA 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:
prettier-plugin-flatbuffers — drop into .prettierrc and .fbs files format alongside the rest of your codebasevscode-flatbuffers — TextMate syntax highlighting + native format-on-save via this engine, no third-party "Run on Save" neededtree-sitter-flatbuffers — incremental-parse + highlights for Neovim, Helix, Zed, GitHub.com, and any tree-sitter consumer0.2.x — under consideration:
--write / --check runs.fbs schemas fetched at CI time and validated against the formatter--validate mode that pipes through flatc (when installed) for semantic-layer checks beyond the grammar1.0 — when:
docs/migrating-to-1.0.md exists for any breaking changes between the last 0.x and 1.0Contributions 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.; 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.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).'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.