Skip to content

Make tuple types usable with array literals, add array destructuring - #116

Merged
batiste merged 1 commit into
masterfrom
tuple-types
Aug 28, 2026
Merged

batiste merged 1 commit into
masterfrom
tuple-types

Conversation

@batiste

@batiste batiste commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Summary

Tuple types were documented as done and had a complete TupleType implementation with thorough unit tests — but no array literal ever produced one, so tuples were unusable in practice. Every ordinary use failed:

t: [string, number] = ['a', 1]              // Cannot assign (string | number)[] to [string, number]
def f(p: [string, number]) {}
f(['a', 1])                                 // expected [string, number] for param 1 but got (string | number)[]
def make(): [string, number] { return ['a', 1] }   // returns (string | number)[]
o: { pair: [string, number] } = { pair: ['a', 1] } // property mismatch
entries: [string, number][] = [['x', 1]]           // mismatch

The only way to get a tuple value was ['a', 1] as const. The existing tests encoded the workaround rather than the gap — tupleTypes.test.blop said "Functions WITHOUT typed parameters so array literals are accepted freely" and left // process([42, 'hello']) commented out.

This PR makes tuples work, and adds the array destructuring they need to be useful.

1. Contextual typing of array literals

At a call site, arguments are inferred before the callee's parameter types are resolved (handleFuncCallAccess reads funcCallNode.inference). Plumbing an expected type down through the visitor would have meant restructuring visit order across five sites. Instead the literal carries its own positional view:

  • ArrayLiteralType (src/inference/Type.js) — an ArrayType subclass that also remembers each element by position. It prints as the array it widens to ((string | number)[], so hover is unchanged) and overrides isCompatibleWith to match a tuple target position by position. One production point in literals.js covers declarations, arguments, returns, object properties, arrays of tuples, aliases and nesting at once.
  • widenFreshness() strips the positional view wherever a value stops being an expression: variable bindings (visitor.js, statements.js), inferred return types (functions.js), and destructuring bindings. So x = ['a', 1] is a (string | number)[] and nothing more, and as const remains what makes a tuple type stick — matching TypeScript.

Diagnostics were actively misleading on the most common failure, since the widened union hides which position is wrong. describeType() renders a literal positionally against a tuple target:

Before After
wrong order Cannot assign (string | number)[] to [string, number] Cannot assign [number, string] to [string, number]
wrong length Cannot assign (string | number)[] to [string, number] Cannot assign [string, number, number] to [string, number]
call site expected [string, number] … but got (string | number)[] expected [string, number] … but got [number, string]
nested Cannot assign (string | (boolean | number)[])[] to … Cannot assign [string, [boolean, number]] to [string, [number, boolean]]

Spreading a tuple now keeps its length known, so [...pair, true] satisfies [string, number, boolean]. This also fixes a latent bug: [...pair] used to infer any[], because a TupleType is not an ArrayType and fell through to the "unknown spread" branch.

2. Array destructuring

[a, b] = pair did not exist in the grammar — only object_destructuring — and failed with a confusing backend error (Token "x" is undefined in the current scope) rather than a parse error. Added across grammar, codegen and inference:

[first, second] = pair                            // binds per position
[host: string, port: number] = pair               // annotated names
[host, port]: [string, number] = pair             // annotated pattern

Each name binds to its own element type — the element type of the tuple position, or the element type for a plain array. Out-of-range reports Tuple [string, number] has no element at index 2; destructuring a clearly non-iterable value reports Cannot destructure 42 as an array (strings are left alone, being iterable in JS). It reuses the existing scope machinery, so redefinition errors and unused-variable warnings behave exactly as they do for object destructuring.

The new assign alternative is ordered before exp:path, which would otherwise read the left-hand side as an array literal. Statements that legitimately start with [ (e.g. [a, b].forEach(…)) still parse as expression statements, and error messages for incomplete statements are byte-identical to the existing { a, b } = behaviour.

Tests

1502 → 1591 tests (137 → 140 files), all passing.

  • src/tests/typeSystem/tupleContextualTyping.test.js — ArrayLiteralType and widenFreshness units, every tuple position, spread behaviour, positional mismatches, message assertions, and the two freshness-must-not-leak cases.
  • src/tests/typeSystem/arrayDestructuring.test.js — element types, annotations, diagnostics.
  • src/tests/languageFeatures/arrayDestructuring.test.blop — runtime behaviour.
  • src/tests/typeSystem/tupleTypes.test.blop — rewritten to use real tuple annotations instead of working around their absence.
  • src/tests/formatter/expressions.test.js — formatting and idempotency for the new syntax.

One existing test changed meaning rather than breaking. constAssertion.test.js asserted that f([1, 2]) must fail for type Pair = [1, 2]; TypeScript accepts that, so the test was encoding the missing feature. It is replaced by tests for what as const actually adds — making the tuple survive a variable binding.

Docs

  • New Tuple Types section in docs/SYNTAX_REFERENCE.md; array destructuring added to the variables and arrays sections. Every example in both was executed against the compiler and produces the stated output.
  • docs/TYPESCRIPT_GAP_ANALYSIS.md corrected: tuples updated, array destructuring added, and generic constraints moved out of "missing" — <T extends string> and K extends keyof T are implemented and enforced, but were listed as Tier 3 not-yet-implemented.
  • CHANGELOG.md entries under 1.3.0.

Verification

npx vitest run      # 1591 passed (140 files)
npm run parser      # regenerated (src/parser.js is in the diff)
npm run build:app   # example app builds, 192 modules

Notes for the reviewer

  • src/parser.js is regenerated from src/grammar.js; the real grammar change is 14 lines.
  • The VSCode linter server (vscode/blop-linter/.../server/src) is not synced in this PR — it is regenerated separately by npm run linter, so the extension will report stale tuple warnings until that is run.
  • Known gaps, recorded in the gap analysis: no rest ([a, ...rest]) or hole ([, b]) patterns in array destructuring.
  • Found while testing, both pre-existing and out of scope: parenthesized type grouping does not parse, so (string | null)[] is rejected; and npx eslint cannot run at all because .eslintrc.js uses module.exports while package.json sets "type": "module".

🤖 Generated with Claude Code

Tuples parsed and had a full Type implementation, but no array literal ever
produced one, so `pair: [string, number] = ['a', 1]` failed with
(string | number)[]. Array literals now infer to ArrayLiteralType, which
remembers its elements by position and satisfies a tuple target through
isCompatibleWith — covering declarations, arguments, returns, object
properties, arrays of tuples, aliases and nesting at once, with no expected
type plumbed through the visitor (at call sites the arguments are inferred
before the callee's parameters resolve, so that would have meant restructuring
visit order).

widenFreshness() strips the positional view at variable bindings and inferred
return types, so `x = ['a', 1]` stays a (string | number)[] and `as const`
remains what makes a tuple type stick, as in TypeScript. Mismatch diagnostics
now name the positions instead of the widened union:
Cannot assign [number, string] to [string, number].

Adds array destructuring (`[a, b] = pair`), previously absent from the grammar
and failing with a backend scope error. Names bind per position, annotated
individually or as a whole pattern. Spreading a tuple keeps its length known,
so `[...pair, true]` builds a longer tuple, and spread tuples now contribute
their element types — `[...pair]` used to infer any[].

One test asserted that f([1, 2]) must fail for `type Pair = [1, 2]`; that
encoded the old limitation (TypeScript accepts it) and now covers what
`as const` actually adds. tupleTypes.test.blop was written around the gap and
now uses real annotations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@batiste
batiste merged commit b97936e into master Aug 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant