Quasiquodo
Compile-time TypeScript quasi-quoting for Rust.
Quasiquodo is a Rust macro that turns inline TypeScript into correct-by-construction syntax trees, giving you TypeScript ergonomics with compile-time type safety.
Instead of writing your syntax tree by hand:
let ast = TsUnionOrIntersectionType;
Quasiquodo lets you write:
let ast = ts_quote!;
Getting Started
Add Quasiquodo to your Cargo.toml:
[]
= "0.3"
Quasiquodo uses SWC to parse TypeScript. It re-exports SWC's syntax tree nodes, but you'll need to add any SWC crates that you use directly—like swc_ecma_codegen for code generation—as separate dependencies.
Minimum supported Rust version
Quasiquodo's minimum supported Rust version (MSRV) is Rust 1.89.0. The MSRV may increase in minor releases (e.g., Quasiquodo 1.1.x may require a newer MSRV than 1.0.x).
Usage
Basic quoting
The ts_quote! macro takes a TypeScript source string and an output kind, and returns the corresponding swc_ecma_ast type:
use ts_quote;
use *;
let ast = ts_quote!;
The output kind—TsType, Expr, ModuleItem, and so on—tells ts_quote! how to parse the source, and which type of syntax tree node to return. You can quote any TypeScript construct that has a corresponding output kind:
let ty = ts_quote!;
let expr = ts_quote!;
let iface = ts_quote!;
Variable substitution
You can use #{var} placeholders to splice variables into the TypeScript syntax tree that ts_quote! builds. Each variable is declared with a name, type, and value:
let name = ts_quote!;
let field_type = ts_quote!;
let ast = ts_quote!;
Placeholders can be used in any position:
let module = "./types";
let ast = ts_quote!;
// => `import type { Pet } from "./types";`
LitStr, LitNum, and LitBool variables replace their placeholders with literal values. LitStr variables in property name and member access positions simplify to plain identifiers when their values are valid identifiers:
let name = "color";
let ast = ts_quote!;
// => `color: string;`
let name = "background-color";
let ast = ts_quote!;
// => `"background-color": string;`
let field = "name";
let ast = ts_quote!;
// => `foo.name`
let field = "some-field";
let ast = ts_quote!;
// => `foo["some-field"]`
Splicing
Vec<T> variables splice naturally into list positions:
- Union and intersection type arms.
- Interface
extendsclauses. - Interface and class bodies.
- Function and constructor parameter lists.
- Call expression arguments.
- Array literal elements.
- Object literal members.
- Import and export specifier lists.
- Block statement bodies.
let name = ts_quote!;
let members = vec!;
let ast = ts_quote!;
This produces:
export interface Pet {
name: string;
age?: number;
}
Some positions, like union and intersection types, require Box<T> wrapping:
let extra = vec!;
let ast = ts_quote!;
// => `string | number | boolean`
Option<T> conditionally includes a single element:
let extra = if include_age else ;
let ast = ts_quote!;
Custom spans
The optional span parameter applies a custom Span to all nodes in the returned syntax tree, which is useful for pointing diagnostics to the right source location:
use ;
let ast = ts_quote!;
JSDoc comments
ts_quote! understands JSDoc-style /** ... */ comments, and supports splicing LitStr variables into them:
use Comments;
let comments = new;
let description = "The pet's name.";
let ast = ts_quote!;
The optional comments parameter collects comments for code generation. Rendering them requires swc_ecma_codegen, which you'll need to add as a separate dependency:
use to_code_with_comments; // From the `swc_ecma_codegen` crate.
let comments = new;
let noun = "pet's name";
let adjective = "required";
let ast = ts_quote!;
let code = to_code_with_comments;
// => `/** The pet's name is required. */ name: string;`
For more complex uses, JsDoc variables let you attach pre-built JSDoc comments to nodes. Each comment is attached to the syntax tree node that follows it:
use ;
use to_code_with_comments;
let comments = new;
let doc = new;
let ast = ts_quote!;
let code = to_code_with_comments;
This produces:
export interface Pet {
/** The pet's name. */ name: string;
}
JsDoc variables can also be embedded in comment text:
let doc = new;
let ast = ts_quote!;
// => `/** This is a pet. */ name: string;`
Option<JsDoc> and Option<LitStr> conditionally attach comments. When the value is None, no comment is emitted:
let doc = if include_docs else ;
let ast = ts_quote!;
// Either `/** The pet's name. */ name: string;` or
// `name: string;`, depending on `doc`.
JsDoc variables propagate through multiple levels of ts_quote!, so you can build a documented member first, then splice it into a larger structure:
let comments = new;
let doc = new;
// Attach the comment to a member...
let member = ts_quote!;
// ...then splice the member into a class.
let class = ts_quote!;
let code = to_code_with_comments;
This produces:
class Pet {
/** The pet's name. */ name: string;
}
Reference
Output kinds
The output kind tells ts_quote! which swc_ecma_ast type to parse from the source.
| Output kind | AST type | Example source |
|---|---|---|
TsType |
TsType |
"string | null" |
Expr |
Expr |
"foo()" |
Stmt |
Stmt |
"return x;" |
Decl |
Decl |
"type T = string;" |
ModuleItem |
ModuleItem |
"export interface Pet {}" |
Ident |
Ident |
"MyType" |
TsTypeElement |
TsTypeElement |
"name: string" |
ClassMember |
ClassMember |
"greet() {}" |
Param |
Param |
"x: number" |
ParamOrTsParamProp |
ParamOrTsParamProp |
"public name: string" |
ImportSpecifier |
ImportSpecifier |
"Foo as Bar" |
ExportSpecifier |
ExportSpecifier |
"Foo as Bar" |
Variable types
Variables can be scalar, boxed, or container types.
Scalar types substitute a single node or literal value:
| Variable type | Rust value type | Description |
|---|---|---|
TsType |
TsType |
A TypeScript type |
Expr |
Expr |
An expression |
Ident |
Ident |
An identifier |
Stmt |
Stmt |
A statement |
TsTypeElement |
TsTypeElement |
An interface member |
ClassMember |
ClassMember |
A class member |
Param |
Param |
A function parameter |
ParamOrTsParamProp |
ParamOrTsParamProp |
A constructor parameter |
ImportSpecifier |
ImportSpecifier |
An import specifier |
ExportSpecifier |
ExportSpecifier |
An export specifier |
Decl |
Decl |
A declaration |
JsDoc |
JsDoc |
A pre-built JSDoc comment |
LitStr |
&str |
A string literal value |
LitNum |
f64 |
A numeric literal value |
LitBool |
bool |
A boolean literal value |
Container types wrap any scalar type:
| Container | Behavior |
|---|---|
Box<T> |
A boxed scalar |
Vec<T> |
Splices zero or more items into a list position |
Option<T> |
Conditionally splices one item or nothing |
How It Works
ts_quote! is a procedural macro that expands to a pure Rust block expression—no parsing, just construction code. All TypeScript parsing happens at compile time.
When the macro runs, it first replaces #{var} placeholders with syntactically appropriate stand-ins, ensuring that the preprocessed source is valid TypeScript. It then parses that source with swc_ecma_parser, and extracts the requested output type from the AST.
The interesting part comes next: Quasiquodo unparses the AST, turning each syntax node into a Rust expression that constructs the equivalent node in your program. As the macro does this, it replaces the #{var} stand-ins with the bound variables. The result is Rust code that builds the AST directly.
Contributing
We love contributions!
If you find a case where Quasiquodo fails, generates incorrect output, or doesn't support an output kind that you need, please open an issue with a minimal reproducing ts_quote! invocation.
For questions, or for planning larger contributions, please start a discussion.
Quasiquodo follows the Ghostty project's AI Usage policy.
Acknowledgments
Quasiquodo builds on the excellent work of the SWC project, whose parser and AST make the whole thing possible, and whose quasi-quotation macro for JavaScript inspired Quasiquodo's design.