@nbe/core and @nbe/markdown on the Zinc compiler

2026-10-21. This report measures what the typed Zinc engine (not --engine quickjs) lacks to compile, unchanged, notion-block-editor/packages/core/src and packages/markdown/src. These two packages hold 6,299 lines across 30 files. The notion-block-editor repository was not modified.

Method

  • Binary: build/zinc, zinc-next 0.4.0+7.g7157543e, at HEAD 3f230aed. It was rebuilt at 11:48 from a tree that holds another session’s uncommitted frontend work (parser.cpp, check.cpp, desugar.cpp, jsx.cpp, ast.h). The early rounds ran on the previous binary. Every repro below was checked again on the current one.
  • Copies: $S/carnet-gaps/{orig,core,markdown}, where $S is the session scratchpad. orig/ is pristine. core/ and markdown/ hold the workarounds, each marked // GAP (215 markers).
  • @nbe/core: mapped through tsconfig paths in carnet-gaps/tsconfig.json ("@nbe/core": ["core/index.ts"]). Zinc honours paths, so no import was rewritten.
  • Loop: zinc check entry.ts. Each error was recorded, reduced to a repro in carnet-gaps/repro/, and worked around in the copy.
    • 145 of the 401 errors in round 3 were mechanical: 70 truthiness and 75 ! for out-of-range reads. autofix.py handled those.
  • Counts are grep counts over the original sources, so they are approximate (±10%).

Result

  • zinc check passes on the worked-around copy. One exception: formula.ts, 900 lines, is stubbed with the same exported API. On its own it needs recursive aliases, a Record<string, Fn> table of closures and forward references between parse closures. With those workarounds applied it still reported 84 errors.
  • smoke.ts does the following:
    1. Creates an Editor and inserts a paragraph.
    2. Types “Hello” and ” world”, bolds 0–5, splits the block and types “Second line”.
    3. Round-trips through docToJSON → blocksToMarkdown → markdownToBlocks.
    4. Runs undo ×2, redo and mergeBackward.
  • Inside smoke.ts, two vitest files were hand-translated: core/test/richtext.test.ts (6 cases) and core/test/ops.test.ts (the round trips plus the throw on deleting a non-leaf).
  • The output is byte-identical to node/tsx on the original sources, both on the interpreter and as an AOT executable (zinc build smoke.ts, 12.5 s). Key lines:
    • markdown: "**Hello** world\n\nSecond line"
    • parsed back: paragraph:**Hello** world | paragraph:Second line
    • every richtext and ops check prints ok.
  • The unchanged copy stops on its first level:
    • 4 circular imports: migrate.ts:3, commands.ts:8, commands.ts:9 and plugin.ts:4.
    • The generic method get<T> at markdown/frontmatter.ts:204.

So the runtime and AOT are sound for this code. Almost every gap is in the frontend (checker, inference, narrowing, typings).

Gaps

“Ban” means the guide forbids the construct on purpose. “Missing” means no document justifies the refusal. Sizes are S (under a day), M (a few days) and L (a week or more). File references are under src/frontend/ unless a path is given.

Parser and modules

GapCountReproWhere to fixSizeKind
Method named get<T>(...) parsed as a getter1class A { get<T>(k: string): T | null { return null } }parser.cpp classDecl (~892): get/set followed by < or ( is a method nameSmissing
export type { X } from './m'1export type { Block } from './types'parser.cpp exportDecl: fixed by the uncommitted work in the main treeSmissing (fixed)
Indexed access types T['k']3type K = Block['type']now parsed (parser.cpp ~303 → TypeOther); check.cpp annotation() says “Syntax not supported yet: indexed access types”. Resolve against the record or interface fieldS–Mmissing
Index signatures { [k: string]: V }2interface M { [k: string]: number }check.cpp annotation(): map to the Record dictionary type (below)Smissing
Import cycles through import type5 edgesa.ts: import type {B} from './b'; b.ts: import {A} from './a'modules.cpp Loader visiting (~1681, 1709, 1756): a type-only edge should not count as a cycle; then support value cycles of declarations, as TS/ESM doMmissing
export * plus a named re-export of the same name, reported as duplicate2export * from './a'; export { x } from './a'check.cpp duplicate export (~4674): same binding, same origin = no errorSmissing
Error location points into generated code1 seenplugin.normalize?.(doc, tx) with tx: any reports editor.ts:16:12desugar.cpp/check.cpp: carry the caller’s span onto synthesized nodesSmissing

Type system

GapCountReproWhere to fixSizeKind
Record<K, V>37const m: Record<string, number> = {}; m['a'] = 1check.cpp annotation() (~459–560): lower to a string-keyed dictionary (Map-backed) with obj[k] get/set and Object.keys/entriesMmissing
Partial, Readonly, ReadonlySet, IterableIterator1+1+1+1function f(p: Partial<Opts>) {}annotation(): Partial = all fields optional; Readonly* = alias of the mutable typeSmissing
this as a return type (): this)7class B { add(): this { return this } }annotation() plus ir/lower.cpp ~203 “this type in the IR”: treat as the enclosing class typeSmissing
Recursive type aliases2 (FormulaValue, Expr)type V = number | string | V[]check.cpp resolveAlias/instantiateAlias (~3872–3884) “refers to itself”: needs a named (nominal) node for the aliasLmissing
Discriminated unions of interfaces~20 uses (Selection)interface A {kind:'a'; x:number}, interface B {kind:'b'}, then if (s.kind === 'a') s.xcheck.cpp narrowByLiteral (~3177): hasLiteral is not set on interface members (~4304). Only inline record unions narrowS–Mmissing
Object literal for an interface that has method signatures5 interfacesinterface S { get(id: string): T | null }, then const s: S = { get(id) { return null } }objectLit (~3669), objAssignable (~201): treat a method signature as a function-typed fieldMmissing
Class assignable to a structural interface1 (BlockStore ← Map)interface Store { get(k: string): B | undefined }, then const s: Store = new Map()assignable (~218): structural check of class members against an interfaceMmissing
Generic instance methods2class C { get<T>(k: string): T | null { ... } }check.cpp ~4491: monomorphize per call site as for free functionsM–Lmissing
Optional any/Dyn fields and params~10function f(a?: any) { return () => a } and { attrs?: any } with attrs omittedir/lower.cpp ~1442/~2473 “uses a missing value” (internal error): the default for an omitted Dyn is not materializedS–Mmissing (bug)
The undefined type name in files that use any/unknown8let x: string | undefined in a file with : unknownmodules.cpp ~3323 rewrites undefined→null only in files without Dyn. annotation() should accept it everywhereSmissing

Inference

GapCountReproWhere to fixSizeKind
Recursive const arrow with no return type8const walk = (b: B) => { b.kids.forEach(walk) }varDecl (~2575) / resolvePending (~3017): infer from the non-recursive returns, or voidSmissing
Forward reference between local const arrows2 (+ all of formula.ts)const a = () => b(); const b = () => 1usedBeforeDeclaration (~203): allow it inside a function body that runs laterSmissing
Callback returning an object literal, not typed from context12const xs: P[] = ys.map(y => ({ id: y }))arrayHof (~1067) / funcExpr (~3324): push the expected element type into the arrow’s returnMmissing
Object-literal method params from context3const s: S = { get(id) { ... } } (id: Dyn)objectLit: take the param types from the expected interfaceSmissing
new Map(iterable), new Set(iterable)7 + 9new Map(pairs), new Set(arr)newCollection (~1369–1384): constructor args and type-argument inferenceS–Mmissing
Empty [] with no annotation~6let out = []; out.push(x)check.cpp ~1994: infer from the first use, or from the declared targetSmissing
Heterogeneous array literals~5[{text:'a'}, {text:'b', marks:['bold']}] as Run[]best common type in arrayLit: use the contextual typeSmissing
Conditional spread5{ ...base, ...(c ? { a: 1 } : {}) }objectLit (~3564 “spread of a non-object”): spread of a union of records = optional fieldsMmissing

Narrowing and truthiness

GapCountReproWhere to fixSizeKind
Truthiness of a non-null string/number/object/Dyn~70if (s) ..., if (!arr.length) ..., if (dyn) ...truthiness() (~2434), condition() (~2461): JS truthiness for every typeMmissing
a && b / a || b returning a value of non-boolean operands~15const t = s || 'default', x && x.ybinaryExpr (~674): the result type is the union of the operandsMmissing
Narrowing of a const captured by a closure~25const b = get(); if (!b) return; xs.map(x => b.id)markNested/isNestedFunction (~3312): a const keeps its narrowed type in closuresSmissing
Assignment in a condition8while ((m = re.exec(s)) !== null) m[1]factsOf (~3187): narrow the assigned variableSmissing
x?.f === y narrows x10if (last?.kind === 'text') last.textfactsOf: an optional chain compared to a non-undefined value implies non-nullSmissing
typeof on a union of primitives15(v: string | number) => typeof v === 'string' ? v.length : vfactsOf / narrowByInstanceOf (~3165): add a typeof factMmissing
Array.isArray on unknown18if (Array.isArray(v)) v.lengthfactsOf: narrow Dyn to Dyn arraySmissing
Optional function field4if (p.normalize) p.normalize(doc)factsOf for a member pathSmissing
Exhaustive switch on a property path3switch (f.op) { case 'eq': return ..; case 'ne': return .. } with no defaultterminates (~2398), missing return (~3069/3449)Smissing
?? or ?. on an array element (noUncheckedIndexedAccess)15 + 4const x = arr[i] ?? def, arr[i]?.idbinaryExpr (~701) rejects ?? on a non-null element. Same root cause as the runtime row “out-of-range read”Mdesign question
User type guard plus in on a generic union1byPrecedence<T> in markdown/index.tsnarrowByInstanceOfMmissing

Standard library and typings

GapCountReproWhere to fixSizeKind
Object.keys/entries/fromEntries/assign17Object.entries(rec)libraryCall (~1421), on top of the Record dictionaryS (after Record)missing
?.method() and ?.[i] on a nullable array or string7 + 7xs?.map(f), m?.[1]optionalCall (~1590), member at ~2255–2262Smissing
sort() with no comparator3keys.sort()arrayHof/libraryCall: default string compareSmissing
flatMap5xs.flatMap(x => x.kids)arrayHof (~1067)Smissing
splice(i, 0, x) insert, unshift(...xs), push(a, b)2 + 2 + 1a.splice(i, 0, x)libraryCallSmissing
Array.from(x, fn), filter(Boolean)2 + 1Array.from(set, f)libraryCall, arrayHofSmissing
n.toString(16)1(255).toString(16)libraryCallSmissing
String(x) / Number(x) of a union or Dyn28 / 7String(v) where v: string | number~1454Smissing
RegExpExecArray type and .index7const m: RegExpExecArray | null = re.exec(s); m.indexannotation() plus the regex builtinSmissing
RegExp type name when no regex literal is present1function f(r: RegExp) {}name registrationSmissing
Intl.Segmenter (graphemes)5new Intl.Segmenter(undefined, {granularity:'grapheme'})runtime builtin over libunibreak, which is already vendoredMmissing
Uint8Array + crypto.getRandomValues, structuredClone, Symbol()1 + 1 + 1crypto.getRandomValues(new Uint8Array(16))structuredClone is mapped to zinc:web (modules.cpp ~1237) but is not available to native programsS eachmissing
Indexing an object literal by a string key1({a:1,b:2})[k]Record dictionarySmissing

Runtime semantics

GapCountReproWhere to fixSizeKind
Out-of-range array read panics instead of yielding undefinedmany sites, criticalconst last = undo[undo.length - 1] on an empty stack (editor, first dispatch)IR array get (ir/lower.cpp): T | undefined on reads typed by noUncheckedIndexedAccess, or arr.at()Mdesign question
Math.random gives the same sequence on every run1 (uuidv7)console.log(Math.random()) twiceseed from the OS outside ZINC_DETERMINISTICSmissing (bug)
JSON.stringify writes null for an undefined field (Dyn files)2JSON.stringify({a: undefined}) → {"a":null}undefined→null rewrite (modules.cpp ~3323)Mmissing
"x" + undefined gives "xnull"1samesame root causeMmissing
delete obj.k3delete rec[k]Z1007–ban: “delete on a typed field (Z1007)”. With Record as a dictionary, delete rec[k] would be Map.delete
k in obj4if ('x' in o)Z9026 (binaryExpr ~678)–ban: “in (Z9026 — use Map.has)”. The guide’s way out works only after Record exists

Both bans quote the “forbidden JS” table of docs/guide/02-language.md. No other refusal met here is justified by a document.

Plan, in order

Each step names what it unblocks. Steps 1–6 make both packages pass zinc check without edits, except formula.ts. Steps 7–8 make the smoke script run unchanged.

  1. Modules: type-only import edges are not cycles, plus value cycles of declarations. Also export * with a named duplicate. M. Unblocks the first-level errors.
  2. Record as a typed dictionary: index signatures, Object.keys/entries/fromEntries/assign, and delete rec[k] / k in rec on dictionaries only. M. 60+ sites.
  3. JS truthiness and &&/|| values for every type. M. About 85 sites, the largest single family.
  4. Narrowing: captured const, interface discriminants (hasLiteral), assignment in condition, x?.f === y, typeof, Array.isArray, optional function fields, exhaustive switch on a path. S–M each, 100+ sites in total.
  5. Contextual typing: arrow returns, object-literal methods, interfaces with method signatures, class-to-interface assignability, [], heterogeneous literals, conditional spread. M.
  6. Small typings: Partial/Readonly/this, undefined everywhere, indexed access, get<T>, Map/Set constructors, sort, flatMap, splice, unshift, push, Array.from, filter(Boolean), toString(16), String/Number of unions, RegExpExecArray.index, ?. calls. S each, one batch.
  7. Lowering bugs: optional Dyn fields and params (internal errors), and error spans in generated code. S–M.
  8. Out-of-range reads: yield undefined (the noUncheckedIndexedAccess model), which also makes arr[i] ?? d legal. M. Without it, Editor panics on the first dispatch.
  9. Generic instance methods. M–L. Needed for frontmatter.ts.
  10. Recursive type aliases and forward references between closures. L. Needed for formula.ts.
  11. Library: Intl.Segmenter over libunibreak, Uint8Array plus crypto.getRandomValues, structuredClone, and a seeded Math.random. S–M.

Outdated statements in docs/guide/02-language.md

  • “compiles … to C++17”: the engine is IR plus interpreter plus AOT to C++20.
  • The “sim oracle”: it was removed with the prototype on 2026-10-20.
  • Generics “compiled to C++ templates”: they are monomorphized in the checker. Generic instance methods are refused.
  • “discriminated unions”: only inline record unions narrow. Interface unions do not.
  • “lib/zinc.d.ts is the exact standard library”: the checker does not read it. It declares Record, Partial, Readonly and Symbol, which the checker rejects.
  • Regex is “forbidden (Z1008)”: regex works, and zinc explain Z1008 reports an unknown code.
  • Codes that do not exist (zinc explain → unknown): Z1016, Z5001, Z5002, Z5003, Z9034, Z9040, Z9041, Z9042.
  • “finally not supported in async (Z9034)”: it works.
  • “await in a loop condition not allowed”: it works.
  • “Calling through a Dyn rejected (Z9042)”: the error is now Z0005 “conversion of function to Dyn” or Z0105.
  • “any is Z1006 in the strict esp32 profile”: zinc run --profile esp32 accepts const a: any.
  • The @weak accessor owner: N | undefined example fails with Z0111.
  • zinc infer: it now only lists the Z0109 sites. It has no --write and does not type .js into .ts.
  • --no-float and --no-dyn: they are not in zinc build usage.
  • The status-return claims of ADR 0006/0007 date from the prototype. They were not verified.

Still true: the TLSF heap (runtime/zrt.cpp, ZRT_HEAP_BYTES), numeric-only enums (string enums are refused), using/Symbol.dispose, and accessors.

Related: docs/reports/research-2026-10-20-js/typescript-as-written.md (the kirari probe) found the same families: truthiness, && values, Record, inference, narrowing, export type from and indexed access.

Edit this page on GitHub ↗