Objects that don't all have the same keys stay compact too: a key some lack becomes an optional field (phone?), and a list that can't be a table is written item by item, with [+] starting each item and . lines continuing it:
import { defineModel, type Infer } from "@flattype/models";
const User = defineModel({ id: "number", fullName: "string", createdAt: "date" });
type User = Infer<typeof User>; // { id: number; fullName: string; createdAt: Date }
const users = User.parseList(await res.json()); // full_name → fullName, ISO string → Date
Or let both ends speak Flat Notation, with JSON for every other client:
import { flatExpress, flatFetch } from "@flattype/http";
app.use(flatExpress()); // server: res.json() answers in Flat Notation when the client asks
const users = await flatFetch("/api/users", { model: User, list: true }); // client: User[]
And in React, Vue, Angular, Svelte or Solid:
import { useFlat } from "@flattype/react"; // or "@flattype/vue": the same API, with refs
const { data: users, loading, error } = useFlat("/api/users", { model: User, list: true }); // User[] | undefined
// Angular: signals on top of HttpClient
users = flatResource(() => "/api/users", { model: User, list: true }); // users.value(): User[] | undefined
// Svelte: a store, $users.data; Solid: a resource
const users = flatStore("/api/users", { model: User, list: true });
const [users] = createFlatResource(() => "/api/users", { model: User, list: true });
Why FlatType
About half the size on list payloads. A list of 1,000 users is 45% smaller than minified JSON and 50% smaller than TOON, because keys are written once and nested fields are flattened into the table header.
Made for LLM context. You pay for every character you send to Claude or another model. Flat Notation is plain key: value text that both models and people read easily, and decode(text, { strict: false }) recovers data from model replies, with a warning for every line it had to repair.
Typed models without boilerplate. Declare the fields you want and get full TypeScript types. snake_case keys, numeric strings, ISO dates, defaults and nullable fields are handled for you, and every problem is reported with its path.
Drop-in for your API. One middleware for Express, Fastify, Hono or any fetch-API server. Clients that ask get Flat Notation, everyone else still gets JSON, and big lists can be streamed row by row.
Lossless.decode(encode(x)) always equals JSON.parse(JSON.stringify(x)). Thousands of random values check this on every test run.
Small and safe. The core is 6.2 KB min+gz, with no third-party dependencies. It's ESM + CJS with full types, works in the browser and Node 18+, and guards against __proto__ pollution.
When JSON is the better choice
One small object per request. Gzip already removes most of JSON's overhead on the wire, so a three-field object saves only a few bytes. The savings come from lists.
Data that only machines read and you don't control both ends. Every JSON tool understands JSON. Flat Notation pays off where you own the client and server, or where the text goes to an LLM.
Mixing the two formats in one value. Decode Flat Notation with decode and JSON with JSON.parse; a JSON string inside a Flat Notation value is just a string.
Flat Notation is line-based. Each line is a path and a value:
name: John
zip: "10001"
user.address.city: Paris
"first name": John
tags[]: admin, editor
empty: {}
none: []
users[2]{id,name,phone?}:
1, Alice, 555-0100
2, Bob,
events[+].type: push
.commits[1]{sha}:
a1f
events[+].type: star
.user: ann
Rule
Example
Nested objects become dot paths
user.address.city: Paris
Arrays of primitives go inline
tags[]: admin, editor
Arrays of objects become a table: count, fields, then one row per line
users[2]{id,name}:
A field that some objects lack is marked ?, and its cell is left empty where the key is missing
{id,name,phone?}
Other arrays are written item by item: [+] starts the next item, and lines starting with . continue it
events[+].type: push
null, true, false and numbers are written as in JSON
age: 30
Strings are bare unless they could be misread, then JSON-quoted
zip: "10001", note: ""
Keys that aren't [A-Za-z0-9_$-]+ are quoted
"first name": John
Lines starting with # are comments
# generated 2026-10-16
Other delimiters are written into the brackets, as tags[|]: and users[2|]{id|name}:, so decode needs no options.
Shared objects and cycles
JSON can't describe the same object in two places, or an object that contains itself. With refs: true, FlatType can: the object is written once, and path = first.path everywhere else (~ is the root).
const alice = { id: 1, name: "Alice" };
const team = { lead: alice, members: [alice, { id: 2, name: "Bob" }] };
alice.team = team; // a cycle
const text = encode(team, { refs: true });
// lead.id: 1
// lead.name: Alice
// lead.team = ~
// members[+] = lead
// members[+].id: 2
// .name: Bob
const back = decode(text, { refs: true });
back.members[0] === back.lead; // true: the same object, not a copy
back.lead.team === back; // true
References are off by default on both ends. Without refs, shared objects are written out in full each time and a cycle throws, exactly like JSON.stringify. The decoder rejects reference lines unless you pass refs: true, because a few such lines can describe a graph that is endless (a cycle) or enormous when another tool walks it. Only turn it on for text you trust, read by code that expects shared objects.
The same hooks as JSON
encode takes a replacer and decode takes a reviver, with the same signatures and call order as JSON.stringify and JSON.parse, so existing code moves over unchanged:
For typed results with validation, @flattype/models is usually the better fit.
Big integers, dates, bytes and other types
JSON has no type for a 64-bit ID, a date or a byte array. MessagePack solves this with binary extension types. Flat Notation keeps the text plain and solves it at the two ends instead.
Big integers are exact. A BigInt is written as its digits, and decode(text, { bigint: true }) reads any integer beyond 2⁵³ back as a BigInt instead of rounding it the way JSON.parse does. Python reads them exactly with no option. So a 64-bit ID from a Python, Go or Java backend arrives intact:
Date-time strings with a zone (…Z, …+01:00, Python's microseconds too) → Date. Date-only strings stay strings.
parseJson
JSON.parse that keeps big integers exact (Node 21+, Chrome 114+)
stringifyJson
JSON.stringify that writes BigInts as digits instead of throwing
Python has the same helpers: encode(x, default=flattype.types.default) handles datetime (written exactly as JavaScript writes dates), bytes, set, Decimal, UUID, enums and dataclasses, and flattype.types.revive_dates turns the strings back into datetimes.
Broken input: where the data is wrong
decode is strict by default: the first problem throws a FlatDecodeError with its line and column. With strict: false it repairs what it can instead, and onWarning tells you what it repaired and where:
const people = decode(text, {
strict: false,
onWarning: (w) => console.warn(`line ${w.line}, column ${w.column}: ${w.message}`),
});
// line 77, column 1: Expected 5 values, got 4: the missing values are null
Each row of a table is one line, so a slip stays in its line. A missing separator in one record of a 197-record file leaves the other 196 exactly right, and the warning names the record to check. A JSON file with one missing colon can't be read at all; see the Edge demo benchmark.
Problem
Lenient repair (the warning says so)
A row with too few or too many values
Missing values are null; extra ones are dropped
A missing value (a:)
null
A duplicate key or index
The last value is kept
A skipped index (a[2] before a[0])
The gap is filled with null, up to 1,000 entries
Fewer table rows than declared
The table has the rows that are there
A table header without its colon
Read as a table anyway
A broken quoted string
Kept as text
With skipInvalidLines: true, a line that isn't Flat Notation
The line is skipped
flattype decode --lenient file.flat prints the same warnings, as warning: file.flat:77:1: ….
Specification and other languages
Flat Notation 1.1 is a complete specification, with the grammar, every error message, and the exact text an encoder writes. Any language can implement it. The conformance suite tests an implementation through a small adapter, so a port can show that it reads and writes exactly what this one does:
1.1 and older readers. Since v1.14, encode writes Flat Notation 1.1, which adds optional table fields and [+] items. Every 1.0 document still means the same, but a decoder older than 1.14 can't read the new syntax. For such readers, encode(value, { target: "1.0" }) (and --target 1.0 in the CLI) writes exactly the text older versions wrote. The HTTP servers choose for you (see HTTP).
Typed models
import { defineModel, infer, toSource, type Infer } from "@flattype/models";
const Address = defineModel({ city: "string", zip: "string" });
const User = defineModel({
id: "number", // "7" is coerced to 7
name: "string",
createdAt: "date", // reads created_at; ISO string or epoch ms → Date
bio: "string?", // string | null, null when missing
role: { type: "string", default: "viewer" },
nickname: { type: "string", optional: true }, // left out when missing
address: Address, // nested model
tags: ["string"], // array
orderId: "bigint", // exact 64-bit IDs (BigInt)
avatar: "bytes?", // base64 → Uint8Array
});
type User = Infer<typeof User>;
User.parse(json); // a JSON value or a Flat Notation string
User.parseList(json.data); // arrays, including Flat Notation tables
User.safeParse(json); // { ok: true, value } | { ok: false, error }
User.serialize(user); // back to API JSON (dates as ISO strings, bytes as base64)
User.encode(users); // straight to Flat Notation
parse throws a ModelError listing every problem with its path, for example address.zip: Required and tags[1]: Expected string, got object. Unknown keys such as password_hash are dropped.
Option
Default
Effect
keys
"auto"
"auto" reads createdAt or created_at. "snake" also serializes back to snake_case. "exact" only reads the field name.
coerce
true
Converts "42" → 42 and "true"/1 → true. Set false to require exact types.
@flattype/http adds Flat Notation to an existing API without breaking anyone. A response is re-encoded only when the request says Accept: text/x-flat, and Vary: Accept keeps caches correct.
import { createClient, flatAxios, flatFetch, streamRows } from "@flattype/http";
const user = await flatFetch("/api/users/7", { model: User }); // typed, throws HttpError on 4xx/5xx
const api = createClient({ baseURL: "https://api.example.com/v1", headers: { authorization } });
const page = await api.get("/users", { query: { page: 2 } });
await api.post("/users", { name: "Chen" }); // JSON request body
const axiosApi = flatAxios(axios.create({ baseURL: "/api" })); // axios: response.data is decoded
for await (const { path, index, value } of streamRows("/api/export")) { // rows while still downloading
table.append(value);
}
A JSON-only server works with every client helper: the response is just read as JSON.
Mixed versions. Clients from 1.14 send Accept: text/x-flat; version=1.1, and the servers write Flat Notation 1.1 only for them. A client on an older version asks without a version and gets 1.0 text, which it can read, so the server and its clients can be upgraded in any order.
React
npm install @flattype/react @flattype/models
import { FlatProvider, useFlat, useFlatStream, useFlatSuspense } from "@flattype/react";
<FlatProvider baseURL="/api" headers={{ authorization: `Bearer ${token}` }}>
<App />
</FlatProvider>;
function Users() {
const { data, error, loading, refetch } = useFlat("/users", { model: User, list: true });
if (error) return <ErrorMessage error={error} />; // HttpError, ModelError or a network error
if (!data) return <Spinner />;
return <UserList users={data} onRefresh={refetch} />; // data: User[]
}
function Profile({ id }: { id: number }) {
const { data: user } = useFlatSuspense(`/users/${id}`, { model: User }); // inside <Suspense>
return <h2>{user.fullName}</h2>; // user: User, never undefined
}
function Export() {
const { rows, done } = useFlatStream("/export", { model: Row, table: "rows" });
return <Table rows={rows} footer={done ? "Done" : `Loading… ${rows.length}`} />; // rows appear while downloading
}
Hook
Returns
Notes
useFlat(url, options)
{ data, error, loading, refetch }
A new URL aborts the old request. Old data stays while the next loads. url: null or enabled: false skips the request
useFlatSuspense(url, options)
{ data, refetch }
Suspends, sends errors to an error boundary, and caches by URL (one request for many components)
useFlatStream(url, options)
{ rows, done, error, restart }
One render per network chunk. table picks one table by path
useModel(data, Model, { list })
the mapped value
Memoized. For data from props, stores or sockets
Works with React 18 and 19, in StrictMode and with any server. A JSON-only API works too. A runnable app with Playwright tests in three browsers is in examples/react-app.
<script setup lang="ts">
import { useFlat, useFlatStream, useFlatSuspense } from "@flattype/vue";
const props = defineProps<{ id: number }>();
// The URL can be a ref or a getter: changing it aborts the old request and loads the new one.
const { data: user, error, loading, refetch } = useFlat(() => `/users/${props.id}`, { model: User }); // Ref<User | undefined>
// Inside <Suspense>, `await` the first response so `data` is always set.
const { data: me } = await useFlatSuspense("/me", { model: User }); // Ref<User>
// Big lists render while they download.
const { rows, done } = useFlatStream("/export", { model: Row, table: "rows" });
</script>
Composable
Returns
Notes
useFlat(url, options)
{ data, error, loading, refetch } refs
url and enabled can be refs or getters. Aborts on change and on unmount
useFlatSuspense(url, options)
Promise<{ data, error, loading, refetch }>
For async setup() in <Suspense>. The first error goes to onErrorCaptured
useFlatStream(url, options)
{ rows, done, error, restart } refs
One update per network chunk. table picks one table by path
useModel(source, Model, { list })
a computed ref
For props, Pinia stores and sockets
Vue 3.3+. A runnable app with Playwright tests in three browsers is in examples/vue-app.
@Component({
imports: [FlatModelPipe],
template: `
@for (u of users.value()?.data; track u.id) { <li>{{ u.fullName }}</li> }
<p>{{ (raw() | flatModel: Address)?.city }}</p>
<p>{{ events.done() ? "Done" : "Loading… " + events.rows().length }}</p>
`,
})
export class UsersComponent {
page = signal(1);
users = flatResource(() => `/users?page=${this.page()}`, { model: UsersPage }); // refetches when page changes
events = flatStream(() => "/export", { model: Row, table: "rows" }); // rows while downloading
raw = toSignal(inject(HttpClient).get("/api/address")); // plain HttpClient works too
}
API
What it does
flatInterceptor
Adds Accept: text/x-flat, application/json and decodes either format, error bodies included. Skip a request with FLAT_TYPE_SKIP in its HttpContext
flatResource(() => url, options)
value, error and isLoading signals plus reload(). Runs on HttpClient and cancels the old request when the URL changes
flatStream(() => url, options)
rows, done and error signals plus restart(). Uses fetch, because HttpClient can't stream
flatModel pipe, mapModel / mapModelList
Model mapping in templates and in RxJS pipelines
Angular 19+ (built with partial compilation, so it works with every later version). A runnable Angular 22 app with Playwright tests in three browsers is in examples/angular-app.
Svelte and Solid
npm install @flattype/svelte @flattype/models
Svelte (3, 4 and 5). The stores follow the Svelte store contract, so $store just works:
<script lang="ts">
import { createFlat } from "@flattype/svelte";
import { derived, writable } from "svelte/store";
const api = createFlat({ baseURL: "/api", headers: () => ({ authorization: `Bearer ${token}` }) });
const users = api.store("/users", { model: User, list: true }); // $users.data: User[] | undefined
const id = writable(1);
const user = api.store(derived(id, (i) => `/users/${i}`), { model: User }); // the URL can be a store
const events = api.streamStore("/export", { model: Row, table: "rows" }); // rows while downloading
</script>
{#each $users.data ?? [] as u (u.id)}<li>{u.fullName}</li>{/each}
<p>{$events.done ? "Done" : `Loading… ${$events.rows.length}`}</p>
A store of { data, error, loading } plus refetch(). Lazy: it fetches on the first subscriber and aborts when the last one leaves. A URL store change aborts the old request
flatStreamStore(url, options)
A store of { rows, done, error } plus restart(). One update per network chunk
modelStore(source, Model)
Maps another store's value to a model
createFlatResource(() => url, options)
A Solid resource, for <Suspense> and <ErrorBoundary>. Aborts on change and on cleanup
createFlatStream(() => url, options)
rows, done and error accessors plus restart()
The stores also work in Solid, or anywhere else, through the store contract: from(flatStore(...)). Runnable apps with Playwright tests in three browsers are in examples/svelte-app and examples/solid-app.
Claude and LLMs
npm install @flattype/ai
import Anthropic from "@anthropic-ai/sdk";
import { countTokens, fromModelOutput, outputInstructions, toPrompt } from "@flattype/ai";
const client = new Anthropic();
// 1. Data in: Flat Notation inside <data> tags, with a one-line legend the model reads once.
const response = await client.beta.messages.create({
model: "claude-opus-5-5",
max_tokens: 16000,
betas: ["server-side-fallback-2026-07-01"],
fallbacks: "default", // if a safety classifier declines, retry on the recommended fallback model
messages: [{
role: "user",
content: `${toPrompt(orders, { name: "orders" })}\n\nTotal spent per customer?\n\n${outputInstructions([{ customer: "Alice", spent: 120 }])}`,
}],
});
// 2. Data out: tolerant of tags, fences, prose and JSON, then validated into typed models.
const reply = response.content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
const totals = fromModelOutput(reply, { model: Total, list: true }); // Total[]
// 3. Measure it with Claude's own tokenizer (count_tokens is free).
const { json, flat, saved } = await countTokens(client, orders);
Tool results shrink too, which matters most for agents that call tools over and over:
import { claudeToolResult, toolResult } from "@flattype/ai";
// MCP server (@modelcontextprotocol/sdk): returns a CallToolResult with Flat Notation text
server.registerTool("list_orders", { description: "…" }, async () => toolResult(await db.orders()));
// Claude API tool loop: a tool_result block for the next user message
messages.push({ role: "user", content: [claudeToolResult(toolUse.id, await runTool(toolUse))] });
API
What it does
toPrompt(data, { name, legend })
<data format="flat-notation"> plus a one-line legend. The block is still valid Flat Notation; put the legend in your system prompt once with legend: false
outputInstructions(example)
Asks the model to answer in Flat Notation and shows the exact shape you expect
Finds the data (last <data> block, code fence, or the text itself), skips prose lines, and accepts JSON too. Repairs go to onWarning, with their line. JSON that doesn't parse throws ModelOutputError, saying where it breaks: The data is JSON, but it is broken at line 1, column 22261: …"language" ⟶ "Galician"…
countTokens(client, data, { model })
{ json, flat, saved } in real Claude tokens, minus per-message overhead. Takes any client with messages.countTokens
MCP CallToolResult and Claude API tool_result blocks
See examples/ai-claude for a token report, a full Claude round trip, and a runnable MCP server (claude mcp add flattype-orders -- node mcp-server.mjs).
CLI
npx @flattype/cli --help # or: npm install -D @flattype/cli, then use `flattype …`
# Convert, in pipes or files
curl -s https://api.example.com/users | npx @flattype/cli encode
npx @flattype/cli decode users.flat -o users.json
# Compare sizes
npx @flattype/cli stats users.json
# bytes gzip
# JSON 169545 12890
# Flat 93606 12568
# saved 45% 2%
# Generate typed models from an OpenAPI spec (3.0, 3.1 or Swagger 2; JSON or YAML)…
npx @flattype/cli gen --openapi openapi.yaml -o src/models.ts
# …or from a real response
curl -s https://api.example.com/users > users.json && npx @flattype/cli gen --sample users.json --name UsersPage
It handles $ref between schemas, allOf, required (other fields become optional), nullable and type: [..., "null"], date and date-time formats, enums and inline objects. A recursive reference becomes "any", and the CLI warns you. Exit code 1 means bad input, 2 means a usage error. From code, use fromOpenAPI(spec) together with toSource(models).
Python
pip install flattype gives Python backends the same format, byte for byte. It has no dependencies and supports Python 3.9+. Thousands of shared test cases check that both languages write and read exactly the same text, errors included.
import flattype
from fastapi import FastAPI
from flattype.asgi import FlatMiddleware
flattype.encode({"name": "John", "age": 30}) # "name: John\nage: 30"
flattype.decode("name: John\nage: 30") # {'name': 'John', 'age': 30}
app = FastAPI()
app.add_middleware(FlatMiddleware) # JSON responses answer in Flat Notation when the client asks
So a FastAPI backend and a React frontend using useFlat talk Flat Notation with one line on each side. flattype.ai has to_prompt, from_model_output and count_tokens for Claude. See the Python README and the FastAPI example.
Benchmarks
pnpm compare on the fixtures in fixtures/ (Node 24, Apple Silicon):
Numbers measured on every docs build are on the benchmarks page. Some honest notes on them:
The savings come from lists. A single small object is about the same size as JSON. Lists whose objects have different keys became tables with optional fields (phone?) and [+] items in v1.14: before that, the contacts list was 18% larger than JSON, and 96% larger after gzip.
Deeply nested, varied objects are about even with JSON. In the activity feed, each event repeats paths like actor.login where JSON nests them once.
Gzip already removes most of JSON's repeated keys, so gzipped network sizes come out close. FlatType makes the biggest difference where text isn't compressed: LLM prompts and tool results, logs, caches and local storage.
JSON.parse is native code, so it's still faster. FlatType decodes at about half its speed and about 8× faster than the TOON reference decoder.
MessagePack is binary, and bigger than you'd think. It saves 5% before compression on the Edge files below but comes out 11% larger than JSON after gzip, because it repeats every key like JSON does. Flat Notation is text you can read, log and put in a prompt.
Long text leaves fewer keys to remove, so the saving is smaller than on typical API lists. Once a response is gzipped, the two are within 2% of each other. Full report, with every size, compression and timing.
Development
pnpm install
pnpm build && pnpm typecheck && pnpm test
pnpm bench # encode/decode vs JSON
pnpm compare # size and parse time vs JSON and TOON
pnpm check:size # bundle budgets
pnpm --filter @flattype/core test:perf # linear-time checks on hostile input
FUZZ_RUNS=50000 pnpm --filter @flattype/core exec vitest run # deep fuzzing, as in CI
pnpm --filter @flattype/docs dev # docs site (VitePress)
pnpm --filter @flattype/playground dev # playground
pnpm video # re-render docs/assets/flattype-intro.{mp4,gif} (needs ffmpeg and the Angular example built)
This is a pnpm + Turborepo monorepo built with tsup and tested with Vitest and fast-check. Releases use Changesets, and all @flattype/* packages share one version number. Every release follows the checklist in ROADMAP.md.
Security
The decoder is prototype-safe, runs in linear time on hostile input, and only ever throws FlatDecodeError. Deep fuzzing and timing tests check this on every push, as described on the security page. Please report vulnerabilities privately, following SECURITY.md.
Contributing
Issues and pull requests are welcome. If you have a payload that FlatType handles badly, please open an issue with a sample; it becomes a test fixture.
คัดลอกคอนฟิก stdio สำหรับ Cursor, Claude Desktop, Claude Code หรือ VS Code ได้ในหน้านี้ คอนฟิกนี้ใช้การติดตั้งของผู้เผยแพร่ ไม่ใช่พร็อกซีของ PlugMCP
@flattype/ai ต้องใช้ API key ไหม?
ระเบียนนี้ไม่ได้ระบุตัวแปรสภาพแวดล้อมที่จำเป็น
@flattype/ai ใช้ทรานสปอร์ตแบบใด?
@flattype/ai ทำงานแบบโลคัลผ่าน stdio
@flattype/ai ถูกตรวจสอบล่าสุดเมื่อใด?
ตรวจสอบล่าสุดเมื่อ 2026-11-13 Flat Notation for LLMs: compact prompts, tolerant parsing of model output, token counting with Claude, MCP and Claude tool results.