Compile TypeScript-based configs to JSON or YAML. Keep configs type-safe, composable, and multi-file — then emit plain data for production.
- Why conf-ts
- Quick start
- Agent skill
- Packages in this monorepo
- Macro transform
- Runtime expression evaluator
- Programmatic API
- Webpack plugin
- Reference: supported TypeScript
- Performance
- Type-safe configs: Author in TypeScript with enums, constants, spreads, and expressions.
- Deterministic output: Produces JSON/YAML with no runtime TypeScript.
- Macro transform (opt-in): Compile-time helpers for casting, array transforms, reusable value modifiers, env injection, and typed runtime expressions.
- Multi-file + path aliases: Works across files and honors
tsconfig.jsonpath aliases.
pnpm add -D @conf-ts/cli// config.conf.ts
export default {
appName: 'My App',
port: 3000,
};conf-ts config.conf.ts
# {"appName":"My App","port":3000}
conf-ts -f yaml config.conf.ts
# appName: My App
# port: 3000That covers plain TypeScript data. When you need compile-time helpers — type casting, array transforms, env injection, or typed runtime expressions — add --macro and import them from @conf-ts/macro; see Macro transform.
conf-ts --macro config.conf.tsAll CLI flags
conf-ts <fileEntry>
# JSON (default)
conf-ts src/config.conf.ts
# YAML
conf-ts -f yaml src/config.conf.ts
# Macro transform
conf-ts --macro src/config.conf.ts
# Single-quoted expr macro output
conf-ts --macro --quote single src/config.conf.ts
# Preserve object key order
conf-ts -p src/config.conf.tsThe compiled output is printed to stdout.
This repo ships an Agent Skill that teaches coding agents how to author conf-ts configs — the supported TypeScript subset, the macro and expr() rules, and how to read a conf-ts compile error. Install it with the skills CLI:
npx skills add Cryrivers/conf-tsIt works with Claude Code, Codex, Cursor, OpenCode, Gemini CLI, GitHub Copilot, and 70+ other agents. Pick targets explicitly, or install globally, with the usual flags:
# Install for specific agents
npx skills add Cryrivers/conf-ts -a claude-code -a codex
# Install once for every project
npx skills add Cryrivers/conf-ts -g
# Try it without installing
npx skills use Cryrivers/conf-ts@conf-ts | claudeThe skill lives in skills/conf-ts and is scoped to writing config files: it deliberately leaves out the CLI, the programmatic API, and the bundler plugins, which are documented below.
| File | Covers |
|---|---|
SKILL.md |
Mode selection, the rules that most often break a config, authoring conventions |
references/config-syntax.md |
Supported and unsupported TypeScript, enums, key ordering, multi-file configs, path aliases |
references/macros.md |
String/Number/Boolean, array macros, modifier, env, nesting and import rules |
references/expr.md |
expr(), exprTemplate(), composition, nested callbacks, LooseExpr, emitted grammar |
references/errors.md |
Every compile error message, its cause, and the fix |
| Package | Purpose |
|---|---|
@conf-ts/cli |
CLI to compile .ts/.conf.ts to JSON/YAML |
@conf-ts/compiler |
Core compiler APIs (compile, compileInMemory) |
@conf-ts/compiler-native |
Native Rust compiler with Node bindings (same API as @conf-ts/compiler) |
@conf-ts/expr-core |
Shared expression lexer, parser, AST types, and parse errors |
@conf-ts/expression |
JavaScript-like runtime expression evaluator |
@conf-ts/macro |
Macro functions consumed by the transform |
@conf-ts/macro-transformer |
TypeScript source transformer for macros |
@conf-ts/macro-transformer-native |
Oxc-backed native source transformer |
@conf-ts/webpack-plugin |
Webpack plugin that emits generated JSON/YAML files |
Enable with --macro. All macros must be imported from @conf-ts/macro.
import { Boolean, Number, String } from '@conf-ts/macro';
export default {
asString: String(123), // "123"
asNumber: Number('1'), // 1
asBoolean: Boolean(0), // false
};import { arrayFilter, arrayFlatMap, arrayMap } from '@conf-ts/macro';
const nums = [1, 2, 3, 4];
export default {
doubled: arrayMap(nums, x => x * 2), // [2, 4, 6, 8]
evens: arrayFilter(nums, x => x % 2 === 0), // [2, 4]
expanded: arrayFlatMap(nums, x => [x, x * 10]), // [1,10,2,20,3,30,4,40]
};Constraints and nested macros
- Each callback must be an arrow function with exactly one parameter.
- The body must be a single return expression (or expression body).
- The callback parameter can be used in property access chains (e.g.,
item.name) and object keys (e.g.,{ [item.id]: item.value }). arrayFilter's returned expression is coerced to boolean to decide inclusion.arrayFlatMapflattens only one level, matching JavaScriptArray.prototype.flatMap.
Macros can be nested inside other macros and within array callbacks; the callback parameter stays correctly scoped during nested evaluation:
import {
arrayFilter,
arrayFlatMap,
arrayMap,
Boolean,
Number,
String,
} from '@conf-ts/macro';
const users = [{ id: 1 }, { id: 2 }, { id: 3 }];
const nums = [0, 1, 2];
export default {
// Macro inside arrayMap callback, parameter is correctly passed
idStrings: arrayMap(users, u => String(u.id)), // ["1","2","3"]
// Nested casting chain
roundTrip: Number(String(42)), // 42
// Multi-layer nesting inside callback
truthyFlags: arrayMap(nums, n => Boolean(Number(String(n)))), // [false, true, true]
// Nested array macros in arguments + callback macro
filteredThenString: arrayMap(
arrayFilter(nums, n => Boolean(n)),
m => String(m),
), // ["1","2"]
// Flat-map arrays by one level
expanded: arrayFlatMap(users, u => [u.id, String(u.id)]), // [1,"1",2,"2",3,"3"]
};import { env } from '@conf-ts/macro';
export default {
nodeEnv: env('NODE_ENV'),
port: Number(env('PORT') ?? '3000'),
};modifier() defines a reusable, type-safe compile-time transformation. Every
argument and the callback result must be statically evaluable; each invocation
is replaced with ordinary config data before JSON/YAML compilation.
import { modifier } from '@conf-ts/macro';
type InputA = { a: number };
type InputB = { b: number };
type Output = InputA & InputB & { extraProperty: number };
const addProperty = modifier<[InputA, InputB], Output>((inputA, inputB) => ({
...inputA,
...inputB,
extraProperty: 3,
}));
export default {
modifierTest: addProperty({ a: 1 }, { b: 2 }),
};{
"modifierTest": {
"a": 1,
"b": 2,
"extraProperty": 3
}
}The callback must be a synchronous arrow function with an expression body.
Zero parameters, optional/default parameters, a trailing rest parameter, and
one level of object/array destructuring are supported. Invocation arguments may
use literals, enums, local/imported const values, static array spreads, and
other macros or modifiers. Modifiers may be forwarded through const aliases
and named/default/namespace/re-export chains, but the modifier function itself
cannot be emitted into configuration data. preserveKeyOrder also applies while
modifier arguments and results are evaluated, including nested modifiers and
object spreads; when using the transformer and compiler as separate APIs, pass
the same setting to both stages.
Expr values inside static inputs remain composable. Optional Expr properties can select a branch at compile time and either extend the supplied expression or reuse a fallback:
import { expr, modifier, type Expr } from '@conf-ts/macro';
type Input = { expression?: Expr<Context, boolean> };
const extend = modifier<[Input], Expr<Context, boolean>>(input =>
input.expression
? expr(ctx => input.expression!(ctx) && anotherConditionExpr(ctx))
: anotherConditionExpr,
);The non-null assertion is only for TypeScript: property narrowing does not carry
into the nested expr() callback. The modifier guard is still evaluated
statically, and the transformer inlines the selected Expr with
precedence-preserving parentheses.
Use expr() when a value can only be evaluated later, against data that's available at runtime — a permission check, a feature-flag rule, a pricing formula. Write it as ordinary, type-checked TypeScript; during JSON/YAML compilation the macro turns it into a portable expression string instead of running it.
import { expr } from '@conf-ts/macro';
enum Status {
Active = 'active',
}
const MIN_AGE = 18;
type UserContext = {
user: { age: number; status: Status };
};
export default {
canEnter: expr<UserContext, boolean>(
ctx => ctx.user.age >= MIN_AGE && ctx.user.status === Status.Active,
),
};{
"canEnter": "user.age >= 18 && user.status === \"active\""
}Evaluate it against runtime data with @conf-ts/expression — see Runtime expression evaluator:
import expression from '@conf-ts/expression';
import config from './config.generated.json';
const canEnter = expression(config.canEnter);
canEnter({ user: { age: 20, status: 'active' } }); // true
canEnter({ user: { age: 16, status: 'active' } }); // falseThe body can also call methods that take their own callback, e.g. ctx.matrix.filter(row => row.some(cell => cell > ctx.threshold)) — see Nested callbacks below.
Constraints, formatting, and composing Expr values
- The callback must be a synchronous arrow function with exactly one identifier parameter and an expression body.
- Root context access must use a property name, such as
ctx.userorctx['user']. Directctxuse is rejected. A computed root key such asctx[key]must resolve to a valid identifier name when compiled. - Nested access, calls, templates, object/array literals, and the operators listed in Runtime expression syntax are supported.
- Assignment, update,
new, regular expression, and other syntax outside that grammar is rejected during compilation (nested callback arguments are the one exception — see below).
Generated expression strings are compact: formatting newlines, tabs, and repeated spaces are collapsed to a single space without changing whitespace inside string or template literal values. String literals use double quotes by default. Set macro transform option quote: 'single' or CLI --quote single to emit single-quoted expression literals instead. The TypeScript and native Oxc transformers normalize expression string literals with the same encoder so their output stays byte-for-byte aligned.
Compiled Expr values can be composed by calling them with the current callback context. The transformer recursively inlines the compiled expression and adds parentheses to preserve operator precedence:
import { expr } from '@conf-ts/macro';
type Context = { a: boolean; b: boolean; c: boolean };
const subCondExpr = expr<Context, boolean>(ctx => ctx.b || ctx.c);
const condition = expr<Context, boolean>(ctx => ctx.a && subCondExpr(ctx)); // "a && (b || c)"Composition supports local const aliases, directly named/default imported Expr
values, and Expr values carried by statically analyzed modifier() or
exprTemplate() arguments (including parameter properties such as
input.condition), at any nesting depth. The argument must be the current
callback's bare parameter identifier (the identifier does not have to be named
ctx). A confirmed Expr called with a property, another value, no argument,
multiple arguments, or a spread argument is rejected during transformation.
Namespace properties outside a static template argument, function-returned Expr
values, and re-export chains are not resolved as composed Expr sources.
exprTemplate() defines a reusable expression template with statically
analyzable parameters. The callback's first parameter is always the runtime
context; every parameter after it is supplied when the template is instantiated
and is folded into the emitted Expr:
import { exprTemplate, type LooseExprTemplate } from '@conf-ts/macro';
type Context = { subtotal: number; customer?: { discount?: number } };
const withTax = exprTemplate<Context, number, [number]>(
(ctx, taxRate) => ctx.subtotal * (1 + taxRate),
);
const singaporeTotal = withTax(0.09);
// "subtotal * (1 + 0.09)"
const discounted: LooseExprTemplate<Context, boolean, [number]> = exprTemplate(
(ctx, minimum) => (ctx.customer.discount ?? 0) >= minimum,
);Template arguments may be literals, enums, imported/local const values, or
other values supported by the constant evaluator: undefined, null, finite
numbers, strings, booleans, arrays, and plain objects. Static array spreads
are supported. A dynamic argument, unsupported value, missing required
argument, or excess argument without a rest parameter is a compile error.
The context parameter must be a plain identifier. Later parameters support optional/default values, a trailing rest parameter, and one level of object/array destructuring (including defaults, holes, renaming, and pattern rest); nested patterns and computed binding keys aren't supported. Default values are evaluated in declaration order and may refer to earlier template parameters or outer constants.
Templates can be forwarded through const aliases, named/default/namespace
imports, and named/default/star re-export chains. A specialized result is an
ordinary Expr and can participate in subExpr(ctx) composition. The template
itself cannot escape into runtime data or be invoked dynamically; it may only
be called or forwarded through those compile-time bindings.
With the macro transform option pruneExprTemplate: true, ternary conditions
that can be decided entirely from template arguments and other static values
are evaluated during specialization. Only the selected branch is emitted, so
an omitted optional argument can select a branch with a check such as
typeof value === 'undefined'. The option defaults to false; disabled
transforms keep the full conditional and skip the extra analysis. Dynamic
conditions that reference ctx are retained even when pruning is enabled.
The expression body can call methods that take their own callback — ctx.queue.filter(i => i < 5), ctx.matrix.filter(row => row.some(cell => cell > ctx.threshold)), ctx.scores.reduce((sum, value) => sum + value, 0), and so on. A nested callback can be an arrow function or a function expression, with either an expression body or a block body containing a single return statement; both forms are down-leveled to the same expression-bodied arrow text that @conf-ts/expression evaluates at runtime. Nested callback parameters may be plain identifiers, one level of object/array destructuring (with defaults, renamed properties, and holes), and a single trailing rest parameter. Callbacks can nest arbitrarily deep and freely reference the outer expr() context and bindings from any enclosing callback.
import { expr } from '@conf-ts/macro';
type Context = { matrix: number[][]; threshold: number };
export default {
countPositiveRows: expr<Context, number>(
ctx =>
ctx.matrix.filter(row => row.some(cell => cell > ctx.threshold)).length,
),
};Not supported inside a nested callback: async/generator functions, type annotations, more than one statement in a block body, and a parameter name that shadows the expr() context parameter or a name bound by an enclosing callback.
Expand
LooseExpr<Context, ReturnType> is a type-only counterpart to Expr<Context, ReturnType>. When a Context has nested optional properties (e.g. { a?: { b?: { c?: number } } }), annotating an expr(...) result as LooseExpr presents the callback with a deeply-required view of Context, so the body can be written without ?. at every level:
import { expr, type LooseExpr } from '@conf-ts/macro';
type Context = { a?: { b?: { c?: number } } };
// No `?.` needed: LooseExpr contextually types `ctx` as deeply required.
const check: LooseExpr<Context, number | boolean> = expr(
ctx => ctx.a.b.c || true,
);Only container types (nested objects, arrays) are made non-optional so that navigation type-checks without ?.; the value ultimately read at the end of a path is still unioned with undefined whenever that path crossed an optional level, even if the leaf field itself isn't declared optional. For { a?: { b?: { c?: { d: string } } } }, ctx.a.b.c.d type-checks without any ?., but its type is string | undefined (not string), since a/b/c being missing at runtime makes d's read short-circuit to undefined too.
LooseContext also recurses into array element types, so indexed access through an array of optional-field objects (ctx.a[0].b.c) works the same way — both at the type level and at runtime, since optionalMemberAccess/loose: true already short-circuits a[b][c]-style bracket access exactly like a.b.c (bracket vs. dot access aren't distinguished). Tuple element positions aren't preserved through LooseContext, since indexed access can't recover which tuple slot was read anyway.
expr()'s compile-time behavior is unchanged — the macro already treats ctx.a.b.c as a plain property chain, which is exactly what optionalMemberAccess/loose: true needs at runtime. Because of that, LooseExpr values must be evaluated with optionalMemberAccess: true (or loose: true); expression() only accepts a LooseExpr argument when one of those is set, and otherwise falls back to a Compiled function that requires the deeply-required shape (matching the fact that, without the option, a missing property really does throw):
import expression from '@conf-ts/expression';
const compiled = expression(check, { loose: true });
compiled({}); // fine: `a` is optional in the original ContextInstall @conf-ts/expression when an application needs to evaluate expressions emitted by expr() or expressions supplied as strings:
pnpm add @conf-ts/expressionimport expression, { compile } from '@conf-ts/expression';
const calculate = expression('subtotal * (1 + taxRate)');
const calculateFast = compile('subtotal * (1 + taxRate)', { strict: true });
calculate({ subtotal: 100, taxRate: 0.08 }); // 108
calculateFast({ subtotal: 100, taxRate: 0.08 }); // 108The default export parses a serialized expression and evaluates its AST through the runtime interpreter. compile() instead emits a specialized JavaScript function for reusable hot paths. Both functions receive a plain environment object whose properties become the serialized expression's root identifiers. Values emitted by the expr() macro are serialized strings at runtime even though the Expr type also carries the callback signature for authoring-time inference.
compile() uses new Function; when dynamic code generation is blocked it falls back to the interpreter unless { strict: true } is set. Source text is never evaluated directly: code generation starts from the validated AST, emits only supported operators, JSON-encodes source-derived strings, and retains own-property-only root lookup. See the package benchmark and implementation notes.
| Category | Supported syntax |
|---|---|
| Literals | Decimal numbers (including exponent notation), strings, booleans, null, undefined |
| Collections | Array literals (including spread elements, e.g. [...a, b]); object literals with identifier/string/computed ({ [key]: value }) keys, shorthand properties ({ a, b }), trailing commas, and object spread |
| Access | Identifiers, object.property, object[key], optional member access (object?.property, object?.[key]) |
| Calls | Functions and methods supplied by the environment; method calls preserve this |
| Functions | Arrow function expressions (x => x * 2, (a, b) => a + b) as callback arguments — expression bodies only, with identifier/destructured/rest/defaulted parameters, nesting, and closures over the surrounding scope |
| Templates | Template literals, nested interpolation, and tagged templates |
| Arithmetic | +, -, *, /, %, ** |
| Comparison | <, <=, >, >=, ==, !=, ===, !==, in, instanceof |
| Bitwise | &, |, ^, ~, <<, >>, >>> |
| Logical | !, &&, ||, ?? with short-circuit evaluation |
| Unary | Unary +/-, typeof, void, delete |
| Control | Parentheses and conditional expressions (condition ? yes : no) |
The parser applies JavaScript-style precedence to the supported operators, including right-associative exponentiation. Not supported: assignments, ++/--, block-bodied statements (arrow functions are limited to expression bodies), new, classes, regular expressions, or comments. See the package README for the full syntax table plus a detailed comparison against other expression-parser libraries.
Options, caching, safety, and LooseExpr evaluation
Pass expression(source, { optionalMemberAccess: true }) (or the equivalent { loose: true } alias) to make non-optional property access behave like optional member access: a.b.c acts like a?.b?.c and returns undefined if the chain crosses null or undefined. The same options work with compile(). Calls are not made optional: an interrupted callee chain such as a.b.c() returns undefined, but calling an existing property whose value is undefined still throws a non-callable error.
Interpreted and compiled expressions have separate 1,000-entry LRU caches by source and option mode (optionalMemberAccess and its loose alias share the same cache bucket). Tooling that needs lexer/parser primitives should import @conf-ts/expr-core instead.
Within the supported grammar, serialized expressions follow JavaScript semantics:
- Missing properties evaluate to
undefined; non-optional access throughnullorundefinedthrows. - Accessor, Proxy, non-callable, and invoked-function errors propagate.
- Unary coercion,
&&,||, optional chaining, array/object spread, computed object keys, array holes,this,delete, andvoidbehave like their JavaScript counterparts. - Errors from runtime callbacks and serialized compiler output are expected to agree by error type and timing; engine-specific message text is not part of the contract.
This package is an evaluator, not a security sandbox. Expressions can read objects and invoke functions exposed through the environment. Do not expose capabilities that untrusted expressions must not access.
LooseExpr values must be evaluated with optionalMemberAccess: true (or loose: true) — see the LooseExpr section above for why.
import { compile } from '@conf-ts/compiler';
const { output, dependencies } = compile('path/to/index.conf.ts', 'json');
// output: string (JSON or YAML)
// dependencies: string[] of files that were evaluatedimport { compileInMemory } from '@conf-ts/compiler';
const files = {
'/index.conf.ts': "export default { foo: 'bar' }",
};
const { output, dependencies } = compileInMemory(
files,
'/index.conf.ts',
'json',
);The compiler also accepts source supplied by a loader or editor. The supplied code wins over the matching file in the optional project snapshot, so compilation never needs a macro-specific API:
compile(
{
filename: '/index.conf.ts',
code: "export default { foo: 'bar' }",
project: { files: { '/index.conf.ts': "export default { foo: 'bar' }" } },
},
'json',
);| Option | Description |
|---|---|
preserveKeyOrder |
Preserves object key insertion order during object creation, serialization, cloning, and merge |
import { compile, compileInMemory } from '@conf-ts/compiler';
compile('path/to/index.conf.ts', 'json', { preserveKeyOrder: true });
compileInMemory(
{ '/index.conf.ts': 'export default { a: 1, b: 2, c: 3 }' },
'/index.conf.ts',
'json',
undefined,
{ preserveKeyOrder: true },
);Macros are a source transform, not a compiler mode. Transform the current module first, then pass the resulting ordinary TypeScript to either compiler:
import { readFileSync } from 'node:fs';
import { compile } from '@conf-ts/compiler';
import {
createMacroProjectSnapshot,
transform,
transformProject,
} from '@conf-ts/macro-transformer';
const filename = 'path/to/index.conf.ts';
const code = readFileSync(filename, 'utf8');
const project = createMacroProjectSnapshot([filename]);
const transformed = transform(
{ filename, code, project },
{
quote: 'single',
env: process.env,
pruneExprTemplate: true,
},
);
const result = compile({ filename, code: transformed.code, project }, 'json');For a multi-file project, use the batch API so project parsing, binding and enum analysis happen once:
const batch = transformProject(
{ project },
{ env: frozenEnvironment, inheritProcessEnv: false },
);
Object.assign(
project.files,
Object.fromEntries(
Object.entries(batch.transformed).map(([file, result]) => [
file,
result.code,
]),
),
);createMacroProjectSnapshot, transformProject, and transform are also exported by @conf-ts/macro-transformer-native with the same project and result shapes. The native snapshot builder scans and resolves the TypeScript project in Rust, so native-only callers do not need to load the TypeScript transformer. Its transformed record is sparse, and each file reports only itself and files actually used during macro evaluation. The compiler only receives ordinary TypeScript and never expands macros.
pruneExprTemplate is an opt-in macro transform option and defaults to
false. When enabled, an exprTemplate() instantiation evaluates a ternary
condition at build time when it depends only on template arguments and other
statically analyzable values, then emits only the selected branch. Leaving the
option disabled preserves the original expression text and avoids the extra
condition analysis. The option is supported by both transformer packages and
by TypeScriptMacroTransformPlugin / NativeMacroTransformPlugin.
ConfTsWebpackPlugin compiles each matching .conf.ts file and writes the generated JSON/YAML next to the source file by default. Add the plugin once — no separate module.rules entry is needed.
// webpack.config.js
const {
ConfTsWebpackPlugin,
TypeScriptMacroTransformPlugin,
} = require('@conf-ts/webpack-plugin');
module.exports = {
plugins: [
new TypeScriptMacroTransformPlugin({
// A pre-loader that expands macros in JS/TS modules.
quote: 'double',
pruneExprTemplate: true,
}),
new ConfTsWebpackPlugin({
// All options are optional.
test: /\.conf\.ts$/, // default
extensionToRemove: '.conf.ts', // default; can also be an array of strings
format: 'json', // 'json' | 'yaml'
name: '[path][name].generated.json', // default; see template tokens below
preserveKeyOrder: false,
check: false, // verify-only mode for CI; reads sidecar file next to source
useWorkers: true, // off-thread compile via piscina; set false for small builds
compiler: 'auto', // 'auto' | 'native' | 'js' — 'auto' prefers @conf-ts/compiler-native if available
}),
],
};extensionToRemove accepts either a string or an array of strings. When you broaden test, pass every suffix that should be stripped:
new ConfTsWebpackPlugin({
test: /\.conf\.(ts|mts|cts)$/,
extensionToRemove: ['.conf.ts', '.conf.mts', '.conf.cts'],
});name supports the following tokens: [name] (source basename without the longest matching extensionToRemove value), [ext] (source extension), and [path] (directory relative to webpack/rspack context). The default is [path][name].generated.${format} so generated files are written beside their source files, not under output.path. check mode resolves the same path and verifies the generated file without writing it.
With compiler: 'auto' (the default), the plugin loads @conf-ts/compiler-native if it's installed and falls back to @conf-ts/compiler otherwise. Force one or the other with compiler: 'native' (errors if the native binding can't be loaded) or compiler: 'js'.
Use NativeMacroTransformPlugin instead when the native Oxc-backed transformer is installed. It intentionally does not fall back to the TypeScript transformer. Its project graph scan, module resolution, incremental reference check, and macro transformation all run in Rust; the TypeScript transformer is not loaded by the native plugin path. The same plugins are available from @conf-ts/webpack-plugin/macro-transform-plugin/typescript and @conf-ts/webpack-plugin/macro-transform-plugin/native.
Benchmarking the TypeScript vs. native plugin
Run pnpm --filter @conf-ts/webpack-plugin bench:compare to compare the TypeScript and native implementations. The benchmark reports module-level module loading, project snapshot, macro transform, compiler, macro pipeline, and full snapshot-to-output pipeline timings, followed by overall webpack cold/watch results for the JavaScript compiler, native compiler, and batched context-module scenarios. Each comparison includes the native/TypeScript ratio and TypeScript-over-native speedup.
Full list of supported and unsupported config syntax
- Literals: string, number, boolean, null
undefinedwith JS serialization semantics- String template literals
- Object/array literals, spreads, shorthand properties
- Array holes serialize like JavaScript arrays (
nullin JSON/YAML output) - Object and array destructuring in
constbindings (including nested patterns, computed keys, default values, and rest) - Enums (string and numeric), including whole enum object expansion
- Property access (including enums)
- Element access:
arr[i],obj["key"],obj[CONST] - Default imports, namespace imports, and re-exports for constants
- Optional chaining:
a?.b,a?.[i],a?.() - Arithmetic and comparison operators (+ - * / % **, equality, and ordering)
- Bitwise operators (
& | ^ << >> >>>) - Logical (
&& || ??),in, andinstanceof Array/Objectoperators - Unary prefix (+ - ! ~),
typeof,void, anddelete - Non-null assertions (
!postfix) - Conditional (ternary)
- Sequence/comma expressions
- Parenthesized and
as/satisfiesexpressions
- Functions (arrow/function expressions) in values
new Date()and othernewexpressions- Regular expressions
let/varfor referenced variables (onlyconstis allowed)
Benchmarked with @conf-ts/tests on complex-types.conf.ts (2s per task, Node v24.11.1, local M-series Mac).
┌─────────┬──────────────────────────┬────────────────────┬──────────────────────┬────────────────────────┬────────────────────────┬─────────┐
│ (index) │ Task name │ Latency avg (ns) │ Latency med (ns) │ Throughput avg (ops/s) │ Throughput med (ops/s) │ Samples │
├─────────┼──────────────────────────┼────────────────────┼──────────────────────┼────────────────────────┼────────────────────────┼─────────┤
│ 0 │ compiler (JS) │ 97685109 ± 1.27% │ 95375041 ± 1973083 │ 10 ± 1.20% │ 10 ± 0 │ 64 │
│ 1 │ compiler-native (Rust) │ 42164 ± 1.09% │ 38750 ± 791.00 │ 24616 ± 0.10% │ 25806 ± 538 │ 47434 │
└─────────┴──────────────────────────┴────────────────────┴──────────────────────┴────────────────────────┴────────────────────────┴─────────┘
In this setup, @conf-ts/compiler-native achieves roughly 2,400× higher throughput than the pure JS compiler on the same config file.
pnpm build
pnpm test
pnpm formatMIT