Skip to content

Make twoslash (\\=>) type validation agnostic of union order#1347

Merged
sindresorhus merged 9 commits intomainfrom
fix/make-twoslash-validator-lint-rule-union-order-agnostic
Feb 7, 2026
Merged

Make twoslash (\\=>) type validation agnostic of union order#1347
sindresorhus merged 9 commits intomainfrom
fix/make-twoslash-validator-lint-rule-union-order-agnostic

Conversation

@som-sm
Copy link
Collaborator

@som-sm som-sm commented Feb 5, 2026

Union order in TS is not guaranteed to be stable and can change due to completely unrelated changes. This beautiful example illustrates this idea clearly.

Here's another example:

type ArrayElement<T extends readonly unknown[]> = T[number];

declare const foo: 'b'; // Presence of this statement affects the union order in `T1` below

type T1 = ArrayElement<['a', 'b']>;
//   ^? type T1 = "b" | "a"

Playground: https://tsplay.dev/weGQ1m

type ArrayElement<T extends readonly unknown[]> = T[number];

// declare const foo: 'b';

type T1 = ArrayElement<['a', 'b']>;
//   ^? type T1 = "a" | "b" (Notice the order changed)

Notice how the order of T1 union changes even though the implementation of ArrayElement itself is unchanged.

This is problematic for our twoslash validator lint rule because it currently doesn't perform an order agnostic type validation. As a result, changes in one part of the codebase can cause twoslash types to fail in completely unrelated locations. And, this also makes the tests for the lint rule flaky.

Working

Currently, this lint rule performs two validations:

  1. Validates that the specified twoslash type is correct.

    // ✅ Correct
    type T = string; 
    //=> string
    // ❌ Incorrect
    type T = string; 
    //=> number
  2. Validates that the specified twoslash type is formatted correctly (for example, use of single quotes, spacing, indentation, etc.).

    // ✅ Correct
    type T = {a: 'a'}; 
    //=> {a: 'a'}
    // ❌ Incorrect
    type T = {a: 'a'}; 
    //=> { a: "a"; }

At the moment, both these validations are performed as part of a single check. The type returned by the getQuickInfoAtPosition API is converted into a properly formatted twoslash comment and then compared directly with the specified twoslash comment. This allows correctness and formatting to be validated in one pass.

With the updated implementation, however, these two validations can no longer be reliably performed in a single check. As a result, they are now handled in two steps. Below is a breakdown of the new approach.

Updated validation flow

  1. Normalize and compare types

    Both the specified type and the actual type are normalized, and their normalized forms are compared. This ensures that validation does not depend on union order. For example, all of the following are treated as valid:

    // ✅ Correct
    type T = 'A' | 'B' | 'C';
    
    type T1 = T;
    //=> 'A' | 'B' | 'C'
    
    type T2 = T;
    //=> 'A' | 'C' | 'B'
    
    type T3 = T;
    //=> 'B' | 'A' | 'C'
    
    type T4 = T;
    //=> 'C' | 'B' | 'A'

    If validation fails at this stage, the fixer suggests the normalized form of the actual type. While it could suggest the raw actual type and defer formatting to the next step, using the normalized form felt more reasonable, as it avoids the need for two separate fixes.

  2. Validate formatting
    Once the specified type is confirmed to be correct, we then validate its formatting.

    // ✅ Correct
    type T = {a: 'a'}; 
    //=> {a: 'a'}
    // ❌ Incorrect
    type T = {a: 'a'}; 
    //=> { a: "a"; }

    This step also enforces that numbers in unions are sorted in ascending order. So, numbers in unions cannot appear in any arbitrary order.

    // ✅ Correct
    type T = 1 | 2 | 3;
    
    type T1 = T;
    //=> 1 | 2 | 3
     Incorrect
    type T2 = T;
    //=> 1 | 3 | 2
    
    type T3 = T;
    //=> 2 | 1 | 3
    
    type T4 = T;
    //=> 3 | 2 | 1

    If this feels too restrictive, this requirement can be easily removed in the future.

    NOTE: Enforcing numeric ordering does not reintroduce the original issue of union-order dependence. The lint rule remains agnostic to union order because this check runs in a separate pass and is purely a formatting validation.


This PR also fixes the following issue:

In unions containing both numbers and non-numbers, the intent was to sort only the numbers while keeping the position of non-numbers intact. However, that wasn't happening correctly. For example:

type T = '1' | '2' | 'a';
//=> '2' | 'a' | '1'

The above twoslash type is considered valid, but it actually is not because the numbers are not sorted. The correct twoslash type in this case would be:

type T = '1' | '2' | 'a';
//=> '1' | 'a' | '2'

Credits to @taiyakihitotsu for pointing this out!


Related:

Repository owner deleted a comment from claude bot Feb 5, 2026
@som-sm som-sm force-pushed the fix/make-twoslash-validator-lint-rule-union-order-agnostic branch from 4873b12 to 6156ae3 Compare February 5, 2026 15:51
@taiyakihitotsu
Copy link
Contributor

@som-sm
This PR addresses the issue #1345.
Hope this helps!

Repository owner deleted a comment from claude bot Feb 6, 2026
@som-sm som-sm force-pushed the fix/make-twoslash-validator-lint-rule-union-order-agnostic branch from 6156ae3 to c7f0d38 Compare February 6, 2026 09:39
Repository owner deleted a comment from claude bot Feb 6, 2026
Repository owner deleted a comment from claude bot Feb 6, 2026
Repository owner deleted a comment from claude bot Feb 7, 2026
Repository owner deleted a comment from claude bot Feb 7, 2026
Comment on lines +87 to +88
incorrectTwoslashType: 'Expected twoslash comment to be: {{expectedComment}}, but found: {{actualComment}}',
incorrectTwoslashFormat: 'Expected twoslash comment to be: {{expectedComment}}, but found: {{actualComment}}',
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While the error message is the same for both cases, having two different error types helps in writing tests in a more controlled fashion.

@som-sm som-sm marked this pull request as ready for review February 7, 2026 13:47
@som-sm som-sm requested a review from sindresorhus February 7, 2026 13:47
@sindresorhus sindresorhus merged commit 6e08190 into main Feb 7, 2026
8 checks passed
@sindresorhus sindresorhus deleted the fix/make-twoslash-validator-lint-rule-union-order-agnostic branch February 7, 2026 19:29
@sindresorhus
Copy link
Owner

Great to have this resolved 🙏

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.

3 participants