tsyntax - TypeScript-syntax DSL strings, parsed identically at the type level and at runtime. One string, two walls.
Write a type as a string and get three things from a single source of truth:
- Compile-time validation of the string itself (
tscrejects"xyz"or"string |"with a pointed message, and offers autocomplete as you type a union). - Type inference - the string's literal type (
"'ltr' | 'rtl' | undefined"infers to"ltr" | "rtl" | undefined). - Runtime validation of values against that string.
The same string is parsed twice - once by TypeScript's type system, once by plain JavaScript - and both parsers agree.
import { dslString, parseValueAgainstDSL, SUPPORTED_KEYWORDS } from "tsyntax";
import type { DSLInfer, DSLValidate } from "tsyntax";
type Dir = DSLInfer<typeof SUPPORTED_KEYWORDS, "'ltr' | 'rtl' | undefined">;
// ^? "ltr" | "rtl" | undefined
const validated = dslString(SUPPORTED_KEYWORDS, "'ltr' | 'rtl' | undefined");
// throws at runtime on a malformed string: "string |", "| string", "xyz", ...
parseValueAgainstDSL(SUPPORTED_KEYWORDS, "'ltr' | 'rtl'", "rtl"); // ok
parseValueAgainstDSL(SUPPORTED_KEYWORDS, "'ltr' | 'rtl'", "center"); // throwsThe DSL is a deliberately small subset of TypeScript type syntax, covering scalars only - no arrays, no objects:
- Primitives -
string,number,bigint,boolean,undefined - Literals -
true,false, numbers (0,1), strings ('a',"a",`a`) - Template literals -
`${number}${'%' | 'px'}`, with any nested scalar DSL inside${...} - Unions -
"string | number | bigint | boolean | undefined" - Recursive tokens - a keyword can reference another DSL string
(
"<length>"->"`${number}${'%' | 'px'}`"), with cycle detection
That restraint is what keeps the type-level parser small enough for one maintainer to own and fast enough to stay out of your editor's way.
Measured, not aspirational:
- 443 lines of source (
src/index.ts+src/types.ts, ~13.4 KB, 3.6 KB gzipped) — of which ~131 lines are the type-level parser and the rest the runtime parser. - 9 exported symbols —
SUPPORTED_KEYWORDS,dslString,parseValueAgainstDSL,extractTokenReferences,detectCircularReferences, and theDSLValidate/DSLInfer/SupportedKeywords/SupportedKeywordsConfigtypes. - Published tarball: 9.7 kB packed / 47.2 kB unpacked.
This is a small parser over a deliberately small grammar — a scalar subset of TypeScript type syntax, not a 1:1 port of TypeScript's compiler or runtime validators.
The type-level parser is autocomplete-aware: after a pipe, the union members
that match your keystrokes are suggested. dslString(SUPPORTED_KEYWORDS, "true | ````) completes true | false. The never passed as the right
side is what triggers this (see the note on PipeWhenExists).
Anything in your keywords config is a valid token. SUPPORTED_KEYWORDS ships
with the primitives plus true/false; extend it with your own vocabulary:
const keywords = Object.assign({}, SUPPORTED_KEYWORDS, {
"<length>": "`${number}${'%' | 'px'}`",
});Keywords whose value is a non-empty string are recursive tokens - the
DSL string is resolved and parsed in place. extractTokenReferences finds
<...> references; detectCircularReferences rejects self-referencing or
cyclically-referencing token graphs.
- Pipe inside single/double-quoted strings (
"'|'") is not supported - the type-level parser splits on|before checking quote boundaries. Template literals (`|`) are exempt and work. - Nested template literals (a backtick literal containing an interpolation) are not supported - tracking escape depth across quote contexts at the type level costs more than the edge case is worth.
Both are documented trade-offs for type-system performance, not oversights.
| Wall | What it catches |
|---|---|
| Compile time | A malformed DSL string fails tsc at the call site, with the offending token named. |
| Runtime | dslString() throws on a malformed string even without tsc in the loop; parseValueAgainstDSL() rejects values that don't match. |
The one-string, two-walls technique - a TypeScript-syntax definition string parsed identically at the type level and at runtime - is the approach proven by ArkType. tsyntax applies it to a deliberately smaller domain: scalar keyword unions, numbers, and template literals, with no arrays and no objects. That restraint is what keeps the type-level parser small enough for one maintainer to own and fast enough to stay out of your editor's way.
You already know the DSL if you know TypeScript: "'ltr' | 'rtl' | undefined" means exactly what it looks like.
Early, honest version: one maintainer, no releases yet. The type-level and
runtime guarantees above are tested (pnpm test). The API surface may still
move.
Prefer the plain name; if you need it under a scope, @ace-code/tsyntax is a
thin re-export of the same library (published from packages/ace-code-tsyntax
in this repo).
MIT