tsv
introduction #
tsv is a Rust toolchain for TypeScript/JS, CSS, and Svelte (and planned HTML/JSON). Today it ships a formatter that closely follows Prettier + prettier-plugin-svelte, and a drop-in for Svelte's parser + acorn + acorn-typescript, plus a type stripper that turns TypeScript into JS by blanking its types to whitespace, so every line and column stays put.
tsv aims to simplify its covered domains and stay lean, and so it makes opinionated choices. The formatter has a single non-configurable style, using Svelte's Prettier config. Among other benefits, this means tsv doesn't depend on a JS runtime, which it would need in order to resolve configs the way Prettier does.
Compared to Oxc, Biome, and swc, tsv is a set of focused tools, not an extensible language platform, so it targets Web standards + TS + Svelte and there's no support for JSX/SCSS/etc. tsv's extensibility story is currently limited to using its Rust crates as libraries (or forking); bridging to JS or wasm plugins is an open question (leaning against).
Compared to rsvelte, tsv has its own TS/JS/CSS parsers instead of using Oxc. rsvelte also ships a Svelte compiler and linter/typechecker integration; tsv has experimental work in that direction that may never ship.
tsv prioritizes, in order:
- correctness (spec conformance for JS/CSS, planned for HTML/JSON; fidelity to Svelte and TypeScript)
- speed
- binary size and memory usage
- extensibility, modularity, reusability
Staying simple is an overarching goal. Feedback is welcome to help navigate these tradeoffs.
See the benchmarks for measurements. Compared to Oxc/Oxfmt and Biome, tsv is faster and smaller and uses less memory, but lacks their features, extensibility, and broad language support. One reason for tsv to exist is to help find the performance left on the table in the Web's implementations.
tsv's formatters are near production-ready, with a long tail of rare bugs, and APIs may still change. Reports and opinions are appreciated. See the issues and discussions.
AI disclosure: this codebase is LLM-generated. It's a high-effort project that prioritizes quality.
These docs are a work in progress. There are more design details in the readme.
Install #
For format-on-save in VS Code and vsix-compatible editors, install the fuzdev.tsv-format extension. It runs tsv's wasm build, so it works in both desktop VS Code and the browser host:
tsv is published to npm as @fuzdev/tsv with native
binaries:
npm i -D @fuzdev/tsv
npx tsv format src
npx tsv format --check src
npx tsv parse src/foo.svelte
npx tsv strip src/foo.ts tsv format writes changed files in place; --check writes nothing
and exits 1 if any file would change. Inside a git repo, discovery honors .gitignore, .formatignore, and .prettierignore;
outside one, only .formatignore.
The native package covers Linux (x64 gnu and musl, arm64 gnu), macOS (arm64 and x64), and Windows x64 - for other platforms use the wasm build below.
The same CLI binaries are also attached to each GitHub Release with a SHA512SUMS and a build provenance attestation, for use without npm.
tsv also ships as wasm, which runs everywhere including browsers and Deno, and provides the
same tsv command:
npm i -D @fuzdev/tsv-wasm
npx tsv format src Both packages claim the tsv bin name. (TBD if the wasm package needs a
different name)
For smaller builds, the formatter and parser also ship solo:
npm i -D @fuzdev/tsv-format-wasm
npm i -D @fuzdev/tsv-parse-wasm Optimally-sized builds for parsing and formatting individual languages may be added upon request. (open an issue)
See the benchmarks for size and performance details.
Usage #
All four packages share one API — the same function names, options, and errors, each solo
package carrying its part — so @fuzdev/tsv and @fuzdev/tsv-wasm are drop-in swaps for each other. Both export the formatter, the parser, and the type
stripper together:
import { format_svelte, parse_svelte, strip_typescript, type Root } from '@fuzdev/tsv';
const formatted = format_svelte('<script>\nconst x=1\n</script>');
if (formatted.ok) console.log(formatted.code);
const parsed = parse_svelte('<script>const x = 1;</script>');
if (!parsed.ok) throw new Error(parsed.error.message);
const ast: Root = parsed.ast;
const stripped = strip_typescript('let x: number = 1;'); The formatter alone:
import { format_svelte } from '@fuzdev/tsv-format-wasm';
const formatted = format_svelte('<script>\nconst x=1\n</script>'); The parser alone:
import { parse_svelte } from '@fuzdev/tsv-parse-wasm';
const parsed = parse_svelte('<script>const x = 1;</script>'); format_typescript, format_css, parse_typescript, and parse_css work the same way. Every export returns a result object rather than
throwing for what it finds in the source: {ok: true, code} from the formatters and {ok: true, ast} from the parsers, whose ASTs are plain
objects in acorn's and Svelte's shapes, with bundled TS types. Their output is checked
against acorn-typescript's and Svelte's at corpus scale, but hasn't yet been fed through
Svelte's compiler end to end.
Each parser has a _json twin (parse_svelte_json and so on) that
returns the AST as a JSON string, {ok: true, json},
skipping the JSON.parse, for callers that pass it along rather than walk it; it
takes every option but locations.
The object parsers also take an acorn-style options object: {locations: true} for per-node line and column
(below), and for TypeScript {sourceType: 'script' | 'module'} (default 'module'). format_typescript takes sourceType too, as does strip_typescript; without it, they retry
as a script when the module parse fails, so a legacy sloppy script needs no options.
A source that doesn't parse returns {ok: false, error} from the parsers, formatters, and type stripper alike. The error is a plain
object shaped like an AST node: kind, a one-line message, a frame with the excerpt and caret, its start/end UTF-16 offsets, and their loc:
import { format_typescript } from '@fuzdev/tsv';
const result = format_typescript('let a;\nconst = ;');
if (!result.ok) {
result.error.kind; // 'parse'
result.error.message; // "Expected identifier or destructuring pattern, found '='"
result.error.frame; // '2:7 const = ;\n ^ here'
result.error.loc.start; // {line: 2, column: 6}
} Its type is TsvParseError, and every error type extends TsvError.
A bad argument — a source that isn't a string, an unknown option — still throws a TypeError, and an engine failure with no position in the source, like an input
over the size cap or a wasm trap, still throws as itself.
The native package needs no initialization; the wasm packages work zero-config in Node.js,
Bun, and Deno (sync auto-init); in browsers and bundlers, call await init() once first.
Type stripping #
strip_typescript, in @fuzdev/tsv and @fuzdev/tsv-wasm, blanks every type to whitespace, so the JS keeps the
TypeScript's lines and columns and needs no source map. TypeScript that blanking can't
erase, like an enum or a parameter property, is refused rather than
transformed, returned as an error with kind: 'unsupported':
import { strip_typescript } from '@fuzdev/tsv';
const result = strip_typescript('let x: number = 1;');
if (result.ok) {
result.code; // 'let x = 1;'
} else {
result.error.kind; // 'parse' or 'unsupported'
} tsv strip does the same for one TypeScript file (.ts, .mts, or .cts), --content, or --stdin,
printing the JS to stdout; it exits 1 on a refusal and 2 on a parse error, and --check prints nothing and keeps those exit codes. See docs/strip.md for the
details.
Line and column #
The parsers return a span-only AST: every node carries its start/end offsets and no per-node loc, as with
acorn's defaults and oxc-parser's. A span-only tree is smaller and faster to hand to JS, and
line and column can be derived from the offsets and the source at any time, without
re-parsing.
import { parse_typescript, create_locator } from '@fuzdev/tsv-parse-wasm';
const source = 'const x = 1;';
// the default AST is span-only: start/end offsets, no per-node loc
const parsed = parse_typescript(source);
// loc on every node, the tree acorn's `locations: true` returns
const located = parse_typescript(source, { locations: true });
// or look up only the positions you need, over one line table
const locator = create_locator(source, { language: 'typescript' });
if (parsed.ok) {
const [declaration] = parsed.ast.body;
locator.position_at(declaration.start); // {line: 1, column: 0}
locator.loc_of(declaration); // {start: {line, column}, end: {line, column}}
} Details:
{locations: true}addslocto every object that hasstart/end, andname_locto the Svelte elements, attributes, and directives that carry one. It's computed in JS from the offsets after the parse, so it costs nothing when off.lochas one definition: the line and UTF-16 column of the node's ownstartandend, breaking lines on ECMAScript's line terminators in TypeScript and on\nalone in Svelte and CSS. For TypeScript that is exactly acorn'slocations: true; for Svelte and CSS it's a superset of the canonical parsers'locthat follows the offsets where Svelte's ownlocdeparts from them.reconstruct_locations(ast, source)runs the same walk over a tree you already hold, mutating it in place.- For sparse lookups,
create_locator(source, {language})builds the line table once, soposition_at(offset)andloc_of(node)cost only the positions you ask for. - The helpers also ship alone, loading no engine, as the
./locationssubpath of every package that parses. - On the command line,
tsv parse --locationsprints the same tree.
Source code #
- github.com/fuzdev/tsv — the formatter, parser, type stripper, wasm bindings, CLI, etc.
- github.com/fuzdev/tsv.fuz.dev — this website
playground #
Try tsv's formatter and parser in the browser playground.
benchmarks #
The benchmarks compare tsv's speed and size with Prettier, Oxc, Biome, and others.
conformance #
The conformance page compares parser coverage on edge-case test suites.