TypeScript conformance: strict defaults, TS2454, and using declarations - #47
Conversation
Review: solid compliance gains, but several confirmed correctness bugs before mergeVerified locally: full local TypeScript-conformance diff vs Confirmed bugs
Noted, not blocking on this PR
Merge mechanics
Working on fixes for items 1–6 now. |
Explicit resource management (ES2026). `using x = expr` binds a resource whose disposal runs when the enclosing block exits on every path — normal completion, return, break, throw — and `await using` awaits the disposal. `using` stays a contextual keyword: it is only a declaration immediately before a binding name on the same line, so existing code using `using` as an identifier is unaffected. Supported at statement position and in both for-statement head forms, where `for (using d of xs)` disposes once per iteration and `for (using d = e;;)` disposes when the loop completes. Rather than teach the code generator a new unwinding mechanism, blocks containing `using` are rewritten into the equivalent try/finally after type checking, so disposal reuses the existing, tested abrupt-completion paths. Multiple resources nest, giving reverse-order disposal and keeping earlier resources protected if a later acquisition throws. Null and undefined resources are legal and dispose to nothing; anything else without a dispose method throws TypeError when the declaration is evaluated, per spec. Adds Symbol.asyncDispose to the VM, realm and type definitions; `await using` prefers it and falls back to Symbol.dispose. Test262 language/statements: +10, no new failures.
Reading a `let`/`var` that was declared with a type annotation but never
assigned on some path reaching the read is now an error, matching TS2454
("Variable 'x' is used before being assigned"). Candidates are declarations
with an annotation, no initializer, no `!` assertion, and a type that does
not admit undefined.
The analysis is a forward dataflow walk per function scope. Conditionals
merge by intersection, with a terminating arm letting the other arm decide
alone; short-circuiting operators treat their right side as maybe-not-run;
loops read pre-loop state. Where control flow is not modelled precisely —
loops, try/catch, switch, class static blocks — every write appearing
syntactically inside is unioned in afterwards, so an unmodelled path can
only silence a diagnostic, never invent one. Candidates touched from a
nested function are dropped entirely, since closures make their flow
unknowable here, as are `var`s that merge with a pre-existing global.
Also teaches the parser the `let x!: T` definite-assignment assertion,
which required consolidating six copies of the declarator annotation
parsing into parseDeclaratorAnnotation.
paserati-testtsc now defaults the strict-family gates to on. TS 6.0 made
`strict` the default and regenerated its baselines to match, so tests with
no directive expect TS2454 and TS2564; defaulting them off cost ~146 tests.
Seven smoke tests used the declare-then-read idiom that TS2454 rejects.
Verified against real tsc, which reports all of them and more, and updated
them to valid strict TypeScript with `!`.
TypeScript conformance: 3197 -> 3444 of 4931 (64.8% -> 69.8%).
…ing` The `using` lookahead in parseForStatement advanced on any `await` after the opening parenthesis, so `for ( await foo ; ... )` was routed into the declaration parser and failed to parse. That cost the 12 top-level-await for-await-expr syntax tests. Both for-head branches now require an actual binding name after `using` before committing, via startsUsingDeclarationAt, which decides from lookahead rather than by advancing first. This also keeps `for (using of xs)` — a for-of over a variable named `using` — working. Test262 language: +12, no new failures.
The flag advertised "verify error codes match" but compared an error-count ratio of 0.5-2.0 and never looked at a code, so a test counted as passing whenever we produced roughly the right *number* of errors, whatever they said. Measured against the conformance suite, only 17% of the tests passing on "we errored" reported anything resembling the diagnostic TypeScript expects, which made the headline number roughly twice the honest one. Diagnostics can now carry the TypeScript code they correspond to, and -strict-errors requires every code a baseline expects to be reported under that code. Codes are compared as sets: TypeScript often reports the same diagnostic at several positions, and comparing counts would mostly measure how aggressively each compiler deduplicates. Errors that carry no TS code yet are counted and surfaced in the failure detail, since they explain most misses while the mapping is filled in. Adds pkg/errors/tscodes.go with the code constants and TSCode(), and checker.addErrorWithCode for tagging diagnostics at the point of emission.
…batim Tags the six highest-impact diagnostics with their TypeScript error codes and rewords them to TypeScript's exact phrasing, so -strict-errors can confirm we report the same diagnostic rather than merely some diagnostic: TS2304 Cannot find name 'X'. TS2322 Type 'X' is not assignable to type 'Y'. TS2339 Property 'X' does not exist on type 'Y'. TS2345 Argument of type 'X' is not assignable to parameter of type 'Y'. TS2454 Variable 'X' is used before being assigned. TS2564 Property 'X' has no initializer and is not definitely assigned... These six were picked by greedily maximising how many conformance tests have their *entire* expected diagnostic set covered, which is what decides a strict pass. The rewording drops detail TypeScript does not include — the name of the variable being assigned, the argument index, whether the target was a variable, property or element. That detail was useful, but matching the compiler users already know is worth more, and the position already points at the offending expression. Conformance under -strict-errors: 1,390 -> 1,830 of 4,933 (28.2% -> 37.1%). The loose figure is unchanged at 3,469, confirming no detection was lost.
…ites Follows up the first mapping pass, which tagged the six highest-impact diagnostics but missed emission sites reporting the same conditions from other code paths — TS2322 was still unmatched on 65 tests and TS2345 on 14, purely because the declarator, spread-argument and overload paths had their own phrasings. Covers the remaining assignability sites in call.go, functions.go and the let/var declarator paths, and adds two parser funnel points that between them account for most syntax diagnostics: TS1003 Identifier expected. TS1005 '<token>' expected. (was: expected next token to be X, got Y) TS1109 Expression expected. (was: no prefix parse function for X found) peekError and noPrefixParseFnError are single choke points, so those three codes come from two small changes. Punctuation token types are already their own source text, which is exactly what TS1005 names. Conformance under -strict-errors: 1,830 -> 1,854 of 4,933 (37.1% -> 37.6%). Loose unchanged at 3,469 and Test262 language unchanged, confirming the rewording cost no detection.
Two diagnostics we already detect but named differently:
TS1100 Invalid use of 'X' in strict mode. (was: SyntaxError: Unexpected
eval or arguments in strict mode / Strict mode function may not
have parameter named 'X')
TS1127 Invalid character. (was: illegal token: X)
Adds Compiler.addErrorWithCode, mirroring the checker and parser helpers, so
compile-stage diagnostics can carry TypeScript codes too.
Conformance under -strict-errors: 1,854 -> 1,893 of 4,933 (37.6% -> 38.4%).
Loose unchanged at 3,470.
Two TypeScript diagnostics we were not reporting under their own codes.
TS18050 ("The value 'null' cannot be used here.") covers `null` and
`undefined` written directly as an operand of an arithmetic, bitwise or
relational operator. TypeScript reports it at the operand and then treats
that operand as non-nullable, so the operator-level complaint never
follows; the check mirrors that by short-circuiting the operator switch.
`+` is exempt when either side could be a string, since that is
concatenation rather than arithmetic.
TS2695 ("Left side of comma operator is unused and has no side effects.")
reports a discarded left operand that cannot do anything. isSideEffectFree
follows TypeScript's list, including its quirk of judging `!x`, `+x`, `-x`
and `~x` inert on the operator alone without inspecting the operand. Two
exemptions are needed to avoid false positives: the `(0, f)(...)`
indirect-call idiom, and --allowUnreachableCode, which several conformance
tests set precisely to keep inert comma operands.
Conformance -strict-errors: 1895 -> 1925 of 4933 (38.4% -> 39.0%).
Test262 language: unchanged.
The future reserved words (implements, interface, let, package, private, protected, public, static, yield) are ordinary identifiers in sloppy mode but reserved once strict mode applies. TypeScript reports them from the binder, so they land alongside the TS2304 the name usually also earns. The three codes differ only in how TypeScript explains why strict mode is in force, so the code follows the context: class body, module, or a plain script compiled with --alwaysStrict. alwaysStrict defaults to off here, unlike the rest of the strict family. Paserati executes sloppy-mode JavaScript faithfully — `var yield = 1` and `function f(yield)` run with the semantics the spec gives them outside strict mode — so rejecting them at check time would refuse code we then happily run. paserati-testtsc turns it on, matching TypeScript 6.0's strict-by-default, which is how every conformance baseline was generated. Class bodies and modules are strict on their own terms and report either way, which is what the new smoke test covers. Conformance -strict-errors: 1925 -> 1936 of 4933 (39.0% -> 39.2%).
`async class`, `async enum`, `async interface` and `async namespace` were already rejected, but as a generic "unexpected token after 'async'" expression error. TypeScript reads these as a misplaced modifier and says so at the `async` itself, so route that shape to TS1042 and leave every other malformed `async` expression on the existing message. Conformance -strict-errors: 1936 -> 1944 of 4933 (39.2% -> 39.4%), which is all eight tests in the cluster. No new clean-fails.
The three codes were declared but never emitted; the operator diagnostics still carried PS codes and our own wording. TypeScript splits "these operands do not work with this operator" by operator. `+` and the relational operators accept several combinations of types, so only the pair is meaningful and it reports TS2365 naming both. The arithmetic, bitwise and shift operators need each operand to be numeric independently, so it checks them one at a time and reports TS2362 or TS2363 at the first bad one — never both, since the check short-circuits. Also gates TS18050 on strictNullChecks, which the original commit missed. Without it `null` and `undefined` are assignable everywhere, so TypeScript never singles out a nullish operand and falls through to TS2365 about the pair instead: `null + undefined` under `@strict: false` is TS2365, not TS18050. plusOperatorWithAnyOtherType.ts is the case that caught it. bigint_number_error.ts loses its "cannot mix BigInt and other types" note, which TypeScript does not have; the test still asserts the mixing is an error, now under TypeScript's wording. Conformance -strict-errors: 1944 -> 1956 of 4933 (39.4% -> 39.7%). No new clean-fails.
Port TypeScript's getSpellingSuggestion/levenshteinWithMax algorithm (pkg/checker/spelling_suggestion.go) and use it at every "Cannot find name" (TS2304) emission site. When an unresolved identifier or type name is close enough to something visible in the environment chain, we now report TS2552 "Cannot find name 'X'. Did you mean 'Y'?" instead of plain TS2304, matching tsc's behavior.
Decorators on an enum, interface, type alias or variable were already rejected at all three sites, just under our own wording and a PS code. TypeScript says "Decorators are not valid here." for every one of them. Conformance -strict-errors: 1960 -> 1966 of 4933 (39.7% -> 39.9%). No new clean-fails.
Splitting a type by truthiness is what gives the logical operators their result types: `a && b` yields the falsy half of `a` or all of `b`, `a || b` the truthy half of `a` or all of `b`, and `a ?? b` the non-nullish half of `a` or all of `b`. `string` and `number` have a single falsy inhabitant each, so their falsy half is `""` and `0`; there is no matching "non-empty string" type, so the truthy half of those stays whole, as in TypeScript. Objects are always truthy and drop to never. `??` deliberately keeps `0` and `""`, which is the difference from `||` that the operator exists for. Not wired into the checker yet — `&&`, `||` and `??` currently all type as `any`, and making them precise will surface errors that the `any` was silently swallowing. Landing the helpers separately keeps that diff to the operator switch itself.
…13 codes
Wire seven already-declared TypeScript diagnostic codes to the checker
diagnostics that correspond to them, matching TypeScript's exact wording:
- TS2300 Duplicate identifier 'X'. (duplicate property in `as {...}` type
assertions; now flags every occurrence, matching tsc, not just the
second one twice)
- TS2367 comparison has no overlap (checkImpossibleComparison)
- TS2378 A 'get' accessor must return a value. (class and object-literal
getters)
- TS2464 computed property name must be string/number/symbol/any
- TS2540 Cannot assign to 'X' because it is a read-only property.
- TS2554 Expected N[-M] arguments, but got M. (regular calls, super()
calls, and `new` expressions; reuses TypeScript's min-max range
formatting for signatures with optional parameters)
- TS18013 private-identifier (#field) access from outside its class,
disambiguated from the `private`/`protected` keyword modifiers (which
stay on generic codes since TS2341/TS2445 aren't in scope here)
TS2411 is declined: TypeScript raises it at interface/class declaration
time when a property's own type conflicts with the type's index
signature. We have no such declaration-time check; our only near-wording
site fires at object-literal assignment time for a different situation,
so attaching the code would mislabel it.
Updated four smoke tests whose expectation strings asserted the old
"cannot assign to readonly property" wording.
Completes the truthiness helpers: ExtractNullishTypes is the test for
whether `??` can ever take its right branch, and UnionWithSubtypeReduction
drops union members already covered by another, so `number | 2` reduces to
`number`. TypeScript applies that reduction to `||` and `??` but not `&&`.
Still unwired — see the note in the previous commit. I did wire it and
measured: conformance moved +1 while introducing 7 new clean-fails, i.e.
seven programs TypeScript accepts that we would start rejecting. The rule
itself is right; what blocks it is three gaps the `any` was masking:
- contextual typing does not flow through `&&` to the right operand, so
`take(flag && (s => s))` loses the parameter type and reports TS2345
- object spread does not distribute over unions, so `{...(flag && base)}`
reports "spread syntax requires an object, got false | {...}"
- a declared literal loses its freshness through the union, so
`let a: "foo" = "foo"; let b = a || "foo"` widens b to string
Each is worth fixing on its own terms. Landing the operator typing before
them would trade a correct rule for seven false positives, which is the
wrong side of that bargain.
Regular function/method calls only checked for too FEW arguments; too MANY went undetected. Mirror the too-few/too-many pattern already used for super() and new expressions: report TS2554 when actualArgCount exceeds the declared parameter count for a non-variadic signature. Rest parameters, optional/default parameters, overloads, any-typed callees, and spread call-site arguments are all unaffected since they either skip this branch entirely or raise the accepted maximum. Also fixes TextDecoder.prototype.decode's builtin type declaration, which claimed 0 parameters while the runtime implementation accepts an optional input argument - a latent bug the new check surfaced via the bug_text_encoder.ts smoke test.
Implements TypeScript's real typing rule for &&/||/?? (union of the
retained half of the left operand with the right operand's type,
short-circuiting when the left operand alone decides) instead of the
long-standing `any` fallback. Landing this correctly required fixing
three gaps it exposed:
- contextual typing didn't flow through a logical operator's right
operand, so e.g. an arrow argument lost its inferred parameter type
- object spread didn't distribute over a union (the common shape of a
logical-operator result), so `{...(cond && obj)}` was rejected
- a literal type produced by union subtype reduction (not literal
syntax) isn't "fresh" and shouldn't widen on an unannotated
let/var — this fix applies narrowly to let/var, not const, which
already has a separate pre-existing widening quirk left untouched
Also extends object destructuring to accept a union of object types,
resolving each property across members (folding in `undefined` where
a member lacks it) instead of rejecting the union outright.
TypeScript conformance (-strict-errors): 2017 -> 2024, with one
understood clean-fail trade (contextuallyTypeLogicalAnd01.ts, which
needs flow-sensitive literal narrowing on plain assignment - a
separate, larger feature - to avoid).
Closes the one known trade-off from the previous &&/||/?? typing fix (contextuallyTypeLogicalAnd01.ts): `let y = true;` followed by `y && expr` needs y's flow type to be the literal `true`, not the declared `boolean`, for the && to short-circuit to `expr` the way TypeScript's control flow analysis does. This is deliberately conservative, not full CFA: a flowNarrowState tracks narrowing only across a straight-line sequence of let/var declarations and bare `name = expr` reassignments within a single Program or block statement list. Any other statement - a branch, loop, function, or anything this code doesn't specifically recognize - invalidates everything tracked so far, since there's no way to tell from here whether it reassigned a tracked variable through a path that wasn't analyzed. That can only lose narrowing TypeScript would have kept, never accept something it would reject. TypeScript conformance (-strict-errors): 2024 -> 2025 (contextual TypeLogicalAnd01.ts now passes), confirmed against two independent same-commit baseline runs to separate the real change from this suite's known batch-vs-isolation flakiness (parser_continueInIter ationStatement4.ts, genericCallWithObjectTypeArgsAndConstraints2.ts - both reproduce identically with and without this change).
A `using`/`await using` declaration can only bind a single disposable
resource, so a destructuring pattern target is always an error. A
for-of loop head unambiguously commits to treating `using` as a
declaration keyword (there's nothing else it could mean there), so
`for (using {} of xs)` is recognized and reported even though the
only declarator is the invalid pattern; a later declarator in a
plain `using a = x, [b] = y` statement is caught the same way once
the statement is already known to be a declaration.
The reverse direction matters too: at plain statement level, `using`
isn't forced to be a declaration keyword by anything, so `using [a] =
null;` is genuinely ambiguous and real TypeScript parses it as the
member-assignment expression `using[a] = null` instead — confirmed
against the TypeScript conformance baseline. An earlier version of
this fix treated both cases identically and wrongly turned that
expression into a bogus TS1492, which a full-suite diff caught as a
new clean-fail before it landed.
TypeScript conformance (-strict-errors): 2025 -> 2027, confirmed
clean (no regressions, no overlap with this suite's known
batch-vs-isolation flaky tests) via a same-commit A/B diff.
PR14's array/object destructuring assignability check used an ad-hoc
'cannot assign type' message; this branch's diagnostic-code work
supersedes it with TypeScript's real TS2322 wording ('Type X is not
assignable to type Y.'). The check itself is unchanged and still fires
correctly - only the expected substring needed updating.
lowerUsingInLoopVariable wrapped the per-iteration body in try/finally with only the dispose call - it never ran buildDisposableGuard, the same per-spec check buildUsingScope performs for plain `using` and `for (using x = e;;)`. A non-disposable per-iteration value ran the loop body first (real side effects happened) and only then crashed on the finally's disposal call with a generic 'undefined is not a function', instead of throwing 'Object is not disposable.' before any iteration. The guard now runs before the try/finally is even entered (matching buildUsingScope's placement), so a failed guard never attempts to dispose what it just rejected.
expectPeekIdentifierOrKeyword only recognized IDENT/yield/get/throw/return for every comma-separated declarator name (let/const/var/using/await using), far narrower than curTokenIsIdentLike's already-established set for binding position elsewhere in the parser. 'using as = ...' and 'using type = ...' failed to parse even though TypeScript allows contextual keywords as using binding names, same as any other declaration. Widened to curTokenIsIdentLike's set rather than the broader isKeywordThatCanBeIdentifier (which also includes reserved words like true/this/super that are valid property names but never valid binding names). This also fixes the same gap for ordinary multi-declarator let/const/var lists, which shared the narrow check. Referencing such a name afterward as an expression (e.g. `as` outside a type position) is a separate, pre-existing limitation not touched here.
…itializer parseDeclaratorAnnotation sets DefiniteAssignment whenever `!` precedes `:`, but nothing downstream cross-checked it against Value != nil. `const x!: number = 5;` compiled and ran cleanly, where real TypeScript rejects the combination with TS1263 - the assertion promises a later, out-of-band write, which an initializer makes meaningless. Added as a syntactic-shape check (unlike TS2454's dataflow analysis, not gated by skipDefiniteAssignment) called from both places declarators are processed: the Pass 2 top-level hoisting loop and the per-scope visit pass that handles nested/function-body declarations. Reproduces for let, const, var, using, and await using alike, since they share parseDeclaratorAnnotation.
Correction to finding #1 (TS2454 shorthand-method false negative)On investigation this is not a shorthand-method-specific gap, and I'm not fixing it as filed. Tracing it down:
Net: not a bug to fix here. If you want TS2454 to track whether a stored closure has actually run before a later read (which real Continuing with the remaining confirmed findings (switch fallthrough, flow-narrowing scope corruption). |
Each case restarted its branch from the pre-switch assigned state, never accounting for reaching a case by falling through from the one immediately before it. A variable assigned in an earlier case and read without its own assignment in a fallthrough case was falsely flagged as used-before-assigned, even though every path reaching that read had already assigned it - real tsc accepts this pattern. Threading the branch forward from one case into the next (rather than copying fresh from the pre-switch state each time) models fallthrough without needing to detect an intervening break: it only ever widens what counts as assigned on a later case, which stays within the analysis's existing bias toward silence over inventing a diagnostic. Verified with a controlled before/after A/B on identically-built binaries: the original repro produces a spurious TS2454 pre-fix and compiles clean post-fix, with a genuine (non-fallthrough) use-before- assign in a switch still correctly flagged.
Correction to finding #4 (flow-narrowing overlay scope corruption)Same pattern as finding #1 — also not fixing this one, and also not a real bug given how the analysis is actually wired up. The repro claims the bug is shadowing-specific: function outer() {
let y = "a";
{ let y = "b"; y = "c"; }
let z: "a" = y; // claimed: false-positive TS2322
}Isolating test — same shape, no shadowing at all, just an unrelated empty block: function outer() {
let y = "a";
{ console.log("hi"); }
let z: "a" = y; // also errors, identically
}Both produce the exact same Net: this is pre-existing, documented conservatism (paserati doesn't attempt to track narrowing across any nested block, matching its stated non-goal of full control-flow analysis), not something PR47 introduced or something actionable as a targeted fix here. Not touching it. That leaves 4 of the original 6 confirmed bugs fixed and landed (the |
Test262 language 23,151->23,154, built-ins unchanged, TypeScript 6.0.3 conformance 3,490->3,491/4,933 (70.7%->70.8%). baseline_language.txt/ baseline.txt regenerated fresh - the copies on disk mid-rebase were stale snapshots from the post-commit hook firing on intermediate commits, not the final fixed state.
c0549e2 to
fc1cbaf
Compare
Raises TypeScript conformance from 3,197 to 3,470 of 4,933 (64.8% → 70.3%) against TypeScript v6.0.3, and adds 22 Test262 language passes.
The conformance checkout was six months stale on
main; it is now pinned to release tagv6.0.3, which is what surfaced the first item below.paserati-testtsc: match TS 6.0 strict defaultsTS 6.0 made
strictthe default and regenerated its conformance baselines to match, so tests with no// @strictdirective now expect TS2564 and TS2454. The runner still defaulted those gates off, which cost ~146 tests and read as a Paserati regression rather than a harness default. Multi-value directives (// @strict: true, false) now resolve to the variant the selected baseline actually carries.TS2454 definite assignment analysis
New
pkg/checker/definite_assignment.go: a forward dataflow walk per function scope. Conditionals merge by intersection, with a terminating arm letting the other decide alone; short-circuiting operators treat their right side as maybe-not-run; loops read pre-loop state. Where flow is not modelled precisely — loops, try/catch, switch, class static blocks — every syntactic write inside is unioned in afterwards, so an unmodelled path can only silence a diagnostic, never invent one. Candidates touched from a nested function are dropped, as arevars merging with a pre-existing global.Also adds the
let x!: Tassertion to the parser, which meant consolidating six copies of the declarator annotation parsing intoparseDeclaratorAnnotation.+101 conformance tests, zero new failures across the suite.
using/await usingdeclarationsExplicit resource management (ES2026), as a contextual keyword at statement position and in both for-head forms. Blocks containing
usingare rewritten into try/finally after type checking, so disposal reuses the existing abrupt-completion paths rather than adding a new unwinding mechanism. Reverse-order disposal, per-iteration disposal infor-of, loop-lifetime disposal infor (using x = e;;), null/undefined as no-ops, and a specTypeErrorwhen a resource has no dispose method. AddsSymbol.asyncDispose.usingDeclarations 43 → 63; Test262
language/statements+10.Two things worth a look
let x: T;-then-read idiom TS2454 rejects. I ran each through realtsc(lib/tsc.js --strict) first — it reports all of them and more — then added!, which preserves each test's subject and changes no runtime behaviour.expressions/functionCalls/typeArgumentInferenceWithObjectLiteral.ts. Bisected to addingasyncDisposeto theSymbolconstructor type. It was only passing because we emit a spurious error on line 31 unrelated to the TS2345 its baseline expects.Symbol.asyncDisposeis real ES2026 and belongs there; the spurious error is a separate pre-existing bug.Known gap
usingat the top level of a module still fails when exports follow it (3 tests): the lowering moves later statements into the try block, putting exported bindings out of module scope. It surfaces as a compile error rather than silent mis-scoping, and is not a regression —usingdid not parse at all before. A proper fix needs declaration hoisting in the lowering.Verification
go test ./tests -run TestScriptsandgo test ./pkg/...green. Test262languagediffs +22 / -0 against baseline. New smoke tests cover definite assignment (positive and negative),usingdisposal semantics, and the for-head lookahead.🤖 Generated with Claude Code