QuickJS that parses TypeScript and JSX
directly. No transpiler, no generated source, no source maps: the parser
erases type syntax as it goes, lowers the handful of constructs that need
code (enums, parameter properties, JSX) straight into bytecode, and reports
errors against the original .ts line.
$ cat hello.tsx
enum Mode { Draft, Live }
interface Props { title: string; mode?: Mode }
const Card = ({ title, mode = Mode.Draft }: Props) => <card mode={Mode[mode]}>{title}</card>;
console.log(JSON.stringify(Card({ title: "hi" })));
$ ./qjs hello.tsx # needs a JSX factory in scope, see "JSX" below
{"type":"card","props":{"mode":"Draft","children":"hi"}}This is upstream QuickJS (release 2026-06-04) plus one commit. Every
upstream file is untouched except for the small set of insertion points
listed under How it is wired in; the implementation
lives in three new headers. The intent is that git merge upstream/master
keeps working forever, so the TypeScript support rides along with each new
QuickJS release. See Merging upstream.
It was built for FrameOS, where scene
apps ship as .ts and run on ESP32 boards with a couple of megabytes to
spare. The transpiler it replaces held a ~1 MB token array for a 36 KB app;
quickts holds nothing, because it consumes types through the parser's own
one-token window. The whole addition is about 16 KB of code.
Command line. qjs and qjsc switch the passes on by file extension:
| extension | TypeScript erasure | JSX lowering |
|---|---|---|
.ts, .mts |
on | off |
.tsx |
on | on |
.jsx |
off | on |
| anything else | off | off |
The same rule applies to modules loaded through import. QJSC_TYPESCRIPT=1
and QJSC_JSX=1 force a pass on in qjsc regardless of extension, which is
what the bytecode-diff test uses.
C API. Two new JS_Eval flags:
#define JS_EVAL_FLAG_TYPESCRIPT (1 << 8) /* erase TypeScript syntax */
#define JS_EVAL_FLAG_JSX (1 << 9) /* lower JSX to factory calls */
/* the flags an extension implies: .ts .mts .tsx .jsx */
static inline int quickts_eval_flags(const char *filename);JSValue v = JS_Eval(ctx, src, len, "app.tsx",
JS_EVAL_TYPE_MODULE | quickts_eval_flags("app.tsx"));A file evaluated without the flags goes through the byte-for-byte unmodified parser. Nothing about JavaScript changes unless you ask for it.
JSX. An element lowers to a call:
<tag a={x} b="y" flag>text{child}</tag>
// → __frameosJsx("tag", {a: x, b: "y", flag: true}, "text", child)
<Comp.Inner /> // → __frameosJsx(Comp.Inner, null)
<>a</> // → __frameosJsx(__frameosFragment, null, "a")Lower-case tags, and tags containing - or :, become strings; anything
else is an expression. Attributes with no value are true; {...spread}
spreads; string values have HTML entities decoded; child text is trimmed
per line, joined with single spaces, and entity-decoded. An element with no
attributes passes null, so a factory can tell <a /> from <a {...{}} />.
The factory and fragment names default to __frameosJsx and
__frameosFragment and are resolved as ordinary free variables at the call
site. Change them at build time:
make 'CFLAGS_OPT=$(CFLAGS) -O2 -DTS_JSX_FACTORY=\"h\" -DTS_JSX_FRAGMENT=\"Fragment\"'or, in your own build, define TS_JSX_FACTORY / TS_JSX_FRAGMENT when
compiling quickjs.c. They are read in quickts_jsx.h.
Everything below is exercised by tests/quickts/cases/, and every case file
is also checked with tsc to make sure it is real TypeScript, not something
only this parser accepts.
Erased — consumed as tokens, nothing emitted, bytecode identical to the stripped source:
- type annotations on
let/const/var, parameters, return types, destructuring patterns,catchbindings,for/for-of/for-inbindings, class fields;?optional and!definite-assignment markers interface,typealiases,declare ...,declare global,declare module, type-onlynamespaces,abstracton classes- generics everywhere: declarations,
extendsconstraints and defaults, call sites (f<T>(x),tag<T>`s`),new X<T>(),new X<T>without parentheses, instantiation expressions (const g = f<string>), generic arrows (<T,>(x: T) => x,async <T>(x: T) => x), JSX elements (<List<Item> items={…} />) x as T,x as const,x satisfies T,x!- class members:
public/private/protected/readonly/override/abstract/declaremodifiers,implements, index signatures, optional methods,thisparameters - bodiless declarations erased whole: function and method overload
signatures, constructor overloads, abstract methods and accessors,
abstractanddeclarefields (leaving those as fields would define an own property that shadows the subclass accessor they were declared for) import type,export type,export type *, inline{ type A, B }specifiers- the full type grammar inside all of the above: unions, intersections,
conditional and mapped types,
keyof/typeof/infer/readonly/unique, tuples, template literal types, function and constructor types, type predicates (x is T,asserts x),typeof import("m"), andMap<string, Array<number>>with its merged>>token split correctly
Lowered — real bytecode is emitted:
enum, including auto-increment, computed members that reference earlier members by name (AB = A | B), string members, and reverse mappings- constructor parameter properties (
constructor(private readonly x: T)), assigned at the top of a base-class constructor and immediately aftersuper(...)in a derived one - JSX and fragments, as above
Each of these has a file in tests/quickts/xfail/ that must keep failing.
- Decorators. They have runtime semantics, and QuickJS itself does not implement the (now stage 3) proposal. An upstream feature, not a quickts one.
namespacethat declares values. Lowering it means an IIFE and a merged object. quickts rejects it (a namespace that declares values is not supported) rather than erasing it — silently dropping the values would be the worst possible failure. Type-only anddeclarenamespaces erase fine.accessorfields (TypeScript 4.9). Rare; write the accessor pair.import x = require("m")andexport = x. ES modules only, which is all QuickJS loads.- The legacy
<T>valueassertion. Unparseable alongside JSX;tscrejects it in.tsxtoo. Useas. f < a > (b)means the generic callf<a>(b).tscreads it the same way, so this is TypeScript being TypeScript, but it is the one place valid JavaScript changes meaning with the pass on. It cannot bite a.jsfile, because the pass is off there.
quickts is a parser, not a type checker: it accepts a superset of
TypeScript (for example an overload signature on a generator, which tsc
refuses). Keep running tsc for diagnostics.
Two ideas carry the whole thing.
Erasure is a type parser that emits nothing. quickts.h contains a
recursive-descent parser for the TypeScript type grammar (ts_skip_type)
plus a balanced-bracket skipper. Where the JavaScript grammar has a slot
that TypeScript fills with a type — after a binding name, after a parameter,
after ), after as — a one-line hook calls into it and the tokens vanish.
Nothing is allocated, nothing is emitted, and the surrounding parser never
knows.
Lowering emits bytecode in place. quickts_enum.h and quickts_jsx.h
use the same emit_op / emit_atom primitives the parser uses for
everything else. An enum becomes let E = {} plus a block scope holding one
const per member — so B = A << 1 resolves lexically, the way tsc
resolves it to E.A. JSX needs no second tokenizer: element syntax is
scanned as raw bytes, and every {expression} hands control back to the real
tokenizer (s->buf_ptr one past the {, next_token(),
js_parse_assign_expr(), resume from one past the }).
Four details are where a first attempt goes wrong, and are worth knowing before touching the code:
- Splitting
>. The tokenizer merges>>,>>>,>=,>>=. InsideMap<string, Array<number>>the closers arrive as oneTOK_SAR.ts_next_gt()re-lexes from one byte past the token start. - A speculative parse must not raise.
f<T>(x)versusf < T > (x)and(a): T => bversusc ? (a) : bare decided by trying a type parse and backing out. The first version built aJSErrorwith a backtrace for every failed guess — every<comparison in the file — and cost 54% of parse time.s->ts_quietturnsts_error()into a bare-1. - The arrow / conditional ambiguity is resolved the way
tscresolves it: the return-type parse after(...)is greedy (soc ? (a) : (b) => deats(b) => das a function type, finds no=>after it, and stays a conditional),(starts a function type only if it passes TypeScript's own "unambiguously a parameter list" lookahead (so(a): ((b) => c) => dis an arrow), and inside the?branch an arrow with a return type is accepted only if the conditional's:still follows its body (soa ? (x): number => x : eis an arrow whilea ? (b) : c => dis a conditional). Every one of those is checked againsttscintests/quickts/cases/14-corners.ts. - JSX errors must point at the raw scan position (
js_parse_error_pos), because the scanner runs ahead of the tokenizer ands->tokenis stale.
quickts.h erasure: ts_skip_type and the hook entry points
quickts_enum.h enum lowering
quickts_jsx.h JSX lowering
tests/quickts/ cases, xfail, runners, a benchmark harness, sample apps
quickts.h is #included into quickjs.c right after
js_parse_seek_token(), so it can use the tokenizer, the save/restore
helpers and js_parse_error(); the other two are included from it.
The hooks in quickjs.c, in file order. Each is a few lines guarded by
s->ts or s->jsx; the whole set is 216 added lines in 39 hunks, with
5 upstream lines modified, and git diff 3d5e064e9d -- quickjs.c shows all
of them.
| where | what it handles |
|---|---|
JSFunctionDef |
lazily allocated list of constructor parameter properties |
JSParseState |
the ts / jsx flags and four bits of lookahead state |
js_parse_skip_parens_token |
the arrow lookahead continues past a return type |
js_parse_property_name |
foo<T>() {} methods, in classes and object literals |
js_parse_class |
class type parameters, extends B<T>, implements, member modifiers, index signatures, bodiless members, ? / ! / : T on fields |
js_parse_destructuring_element |
({a}: T = {}) |
js_parse_postfix_expr |
x!, f<T>(y), JSX at the head of an expression |
js_parse_expr_binary (relational level) |
as, satisfies |
js_parse_cond_expr / js_parse_assign_expr2 |
the ?-branch arrow rule, generic arrows |
js_parse_var |
let x!: T |
js_parse_for_in_of |
for (const x: T of …) |
js_parse_statement_or_decl |
interface, type, declare, namespace, enum, abstract class, catch (e: T) |
js_parse_export / js_parse_import |
export type, export enum, export default abstract class, import type, inline type specifiers |
js_parse_function_decl2 |
overload signatures, type parameters, this and typed parameters, return types, parameter-property assignment |
__JS_EvalInternal |
reads the two flags |
quickjs.h gains the two flags and quickts_eval_flags(). qjs.c,
qjsc.c and the module loader in quickjs-libc.c map file extensions to
flags. The Makefile gains test-quickts.
make # builds qjs and qjsc as usual
make test # upstream's suite, unchanged
make test-quicktstest-quickts runs four things:
tests/quickts/run.py— self-checking.ts/.tsxfiles that must printok, plusxfail/files that must keep failing. A.jscase is run twice, as itself and renamed to.tsx, and both must agree.upstream_as_ts.sh— QuickJS's own test suite copied to.tsand.tsx. Every file is plain JavaScript; the passes must change nothing.bytecode_diff.sh— compiles JavaScript withqjsctwice, both passes off and both forced on, and requires byte-identical output. This is the direct check on the only failure that matters for shipped code.corpus_parse.sh— parses a corpus and reports syntax errors by message. With no arguments it uses the bundled sample apps; point it at a real project with$(git ls-files '*.ts' '*.tsx'). The FrameOS repository (671.ts+ 356 React.tsx) parses 1026 of 1027, the holdout beingimport './index.css'.
tests/quickts/probe.sh is a scratchpad of one-liners for checking a
construct quickly; tests/quickts/bench.c measures parse time and peak
parser heap through a wrapping allocator.
Measured on arm64 with clang against pristine QuickJS 2026-06-04, on a 36 KB TypeScript module:
| pristine | quickts | |
|---|---|---|
quickjs.o __text, -Os |
482,460 B | 498,220 B (+15.8 KB, +3.3%) |
| parse time, flags off | 0.649 ms | 0.650 ms |
| parse time, flags on | — | 0.685 ms (+4%) |
| peak parser heap | 159,452 B | 159,764 B (+312 B) |
The 312 bytes are a pointer and a count on each JSFunctionDef. Erasure
alone is about 7 KB of the code; JSX and enums are the rest.
A quickts release is named after the upstream release it sits on plus a
counter: 2026-06-04-quickts.1 is the first quickts on top of QuickJS
2026-06-04. VERSION carries that string, so qjs --version and
CONFIG_VERSION say which one they are. Each release is a git tag
(v2026-06-04-quickts.1) with a source tarball attached that unpacks to
quickjs-<version>/, the same layout as Bellard's tarballs, so a build that
fetched quickjs-2026-06-04.tar.xz only needs a new URL and checksum.
FrameOS mirrors the tarball at
https://archive.frameos.net/source/vendor/quickjs-<version>.tar.xz and
publishes prebuilt libquickjs.a archives per distro/arch from it
(tools/prebuilt-deps in the FrameOS repository).
Add Bellard's repository as a remote and merge:
git remote add upstream https://github.com/bellard/quickjs
git fetch upstream
git merge upstream/master
make && make test && make test-quicktsBecause the change is insertions into a handful of functions, upstream edits
elsewhere in quickjs.c merge cleanly, and an upstream edit inside one of
the hooked functions shows up as an ordinary conflict next to a clearly
marked block. After resolving, the four test runs above are the check that
matters; bytecode_diff.sh in particular will catch a hook that started
firing on JavaScript.
Things that would need attention if upstream changed them:
- the token constants (
TOK_SAR,TOK_SHR,TOK_GTE, …) thatts_starts_with_gt()splits; js_parse_skip_parens_token()'s contract of returning the token after the closing parenthesis;- the
OP_*opcodes used by the two lowering headers (OP_define_field,OP_put_array_el,OP_copy_data_properties,OP_call, …) — all of them are used the same way by upstream's own object-literal and call code, so a change there would be visible injs_parse_object_literaltoo.
quickts adds no atoms to quickjs-atom.h on purpose: contextual keywords
(type, as, satisfies, readonly, …) are matched by comparing source
bytes, so the atom table and TOK_* numbering stay exactly upstream's.
MIT, the same as QuickJS. The QuickJS copyright notices are unchanged; the quickts additions are Copyright (c) 2026 Marius Andra and released under the same terms.