Skip to content

Spell Selector without the self-reference its base type holds - #350

Open
firefoxic wants to merge 3 commits into
postcss:mainfrom
firefoxic:fix-selector-type-ts5115
Open

firefoxic wants to merge 3 commits into
postcss:mainfrom
firefoxic:fix-selector-type-ts5115

Conversation

@firefoxic

Copy link
Copy Markdown

Closes #348.

Selector no longer checks on the TypeScript 7.1 nightlies, since the nightly of 2026-09-29. The workaround of #232 spells it as a type alias over an interface parameterized by itself, and the nightlies refuse to check the cycle that leaves behind, with TS5115 on every use of the type.

No other spelling of the exclusion survives every compiler — they are all in the issue #348 — because a Selector that says its children are the nodes that are not selectors has to name the node union in its own base type, and that union holds Selector. This PR gives the exclusion up, which is the one spelling that costs nothing anywhere:

-    interface _Selector<S> extends Container<string, Diff<Node, S>> {
+    interface Selector extends Container<string, Node> {
         type: "selector";
     }
-    type Selector = _Selector<Selector>;

Measured with tsc --noEmit --strict, on a consumer that narrows a selector's children by type and reads them:

4.0.3 4.9.5 5.0.4 5.9.3 7.0.2 7.1 nightly
as shipped ok ok ok ok ok TS5115
here ok ok ok ok ok ok

What is lost: selector.nodes was Array<Diff<Node, Selector>>, so narrowing a selector's children by type could not reach a selector. The parser never builds one, postcss-selector-parser.test.ts narrows by type before reading anything, and npm run typecheck and npm run tests pass. But this is a loosening of the type rather than a repair of it, and if a spelling that keeps the exclusion is preferred, this is the change to argue with.

Diff was only used there, so it goes with it.

The workaround of 2020 for postcss#232 spells `Selector` as a type alias over an interface parameterized by itself, so that the base type of `Selector` is not `Selector` itself. The TypeScript 7.1 nightlies refuse to check that, with `TS5115` on every use of the type, reported where the type is used rather than in the declaration, so `skipLibCheck` passes over it and it reaches whoever instantiates `Selector`.

Writing the self-reference back into the interface is `TS2310` again, which is what postcss#232 was about, and every other spelling costs the same on some compiler: a `Selector` that says its children are the nodes that are not selectors has to name the node union in its own base type, and that union holds `Selector`. The exclusion is given up instead, so a selector's children are typed as any node. The parser never puts a selector inside a selector, and the type test narrows by `type` before reading, so nothing in this package relies on the exclusion — but it is a loosening, and a different trade may suit this package better than this one does.
@MoOx

MoOx commented Nov 8, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the thorough table in #348, it made this easy to reason about.

I would like to avoid widening the public type if we can. I built the three spellings against a consumer that narrows selector.nodes by type and ends on an assertNever exhaustiveness check, and measured them on the TypeScript this repo pins (5.9.3):

spelling TS 5.9.3
as shipped ok
this PR, Container<string, Node> TS2345 on the consumer: Argument of type 'Selector' is not assignable to parameter of type 'never'
Container<string, Node> plus nodes on Selector itself ok

So dropping the exclusion does break consumers that switch exhaustively over a selector's children, which is a realistic pattern and the reason the exclusion was there.

The third spelling is the second option you listed in #348, declaring nodes on Selector rather than through Container, and it is the one line your table does not cover. Could you run it against the 7.1 nightly? I could not, the environment I tested in cannot reach packages published in the last week. On top of your branch it would be:

    interface Selector extends Container<string, Node> {
        type: "selector";
+       nodes: Array<Diff<Node, Selector>>;
    }

keeping Diff rather than removing it. If that reports TS5115 too, then your spelling is the right trade and I will take this PR as it stands, with a changelog note about the widening.

@firefoxic

firefoxic commented Nov 8, 2026 •

Copy link
Copy Markdown
Author

Thanks for suggesting this alternative. I tested both versions of the separate nodes property on top of Container<string, Node>:

  • nodes: Array<Diff<Node, Selector>>
  • nodes: Array<Exclude<Node, Selector>>

For each version, I used a consumer that narrows selector.nodes by type, reads the narrowed node, and checks exhaustiveness with an assertNever fallback. Both versions failed on TypeScript 4.0.3, 5.9.3, 7.0.2, and 7.1.0-beta with TS2345: TypeScript still considers Selector possible in the fallback branch.

So declaring nodes separately avoids putting the recursive exclusion in Container's base type, but neither Diff nor Exclude preserves the exclusion for this consumer. The Container<string, Node> version without a separately narrowed nodes property remains the only spelling I have verified to pass the compiler matrix, at the cost of widening selector.nodes.

@MoOx

MoOx commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator

Thanks for testing both spellings. I get the opposite result, so one of our two consumers must differ, and rather than argue about it here is mine in full. If it fails for you, the difference will be obvious in a minute, and I will take this PR as it stands.

What I measured, same consumer in all three cases, with only the declaration and the consumer in the compilation:

spelling 4.0.3 4.9.5 5.9.3 7.0.2
as shipped ok ok ok ok
this PR, Container<string, Node> TS2345 TS2345 TS2345 TS2345
nodes declared on Selector ok ok ok ok

The exact edit for the third row, applied to the published postcss-selector-parser.d.ts:

-    interface _Selector<S> extends Container<string, Diff<Node, S>> {
-        type: "selector";
-    }
-    type Selector = _Selector<Selector>;
+    interface Selector extends Container<string, Node> {
+        type: "selector";
+        nodes: Array<Diff<Node, Selector>>;
+    }

Diff is kept rather than deleted. consumer.ts:

import parser = require("./postcss-selector-parser");

function assertNever(x: never): never { throw new Error("unexpected " + x); }

export function readChildren(sel: parser.Selector): string[] {
  const out: string[] = [];
  for (const n of sel.nodes) {
    switch (n.type) {
      case "attribute": out.push(n.attribute); break;
      case "class": out.push(n.value); break;
      case "combinator": out.push(n.value); break;
      case "comment": out.push(n.value); break;
      case "id": out.push(n.value); break;
      case "nesting": out.push(n.value); break;
      case "pseudo": out.push(n.value); break;
      case "root": out.push("root"); break;
      case "string": out.push(n.value); break;
      case "tag": out.push(n.value); break;
      case "universal": out.push(n.value); break;
      default: return assertNever(n);
    }
  }
  return out;
}

tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "noEmit": true,
    "module": "commonjs",
    "target": "es2018",
    "skipLibCheck": false
  },
  "files": ["postcss-selector-parser.d.ts", "consumer.ts"]
}

One thing to watch if you reproduce it: leaving postcss-selector-parser.test.ts in the compilation buries the signal. On 4.0.3 it fails with TS1005 on syntax that compiler does not know, and on 7.0.2 it fails with TS2349 on the export = call. Both happen for all three spellings and have nothing to do with Selector, which is why I compile only the declaration and the consumer.

My matrix stops at 7.0.2 because the environment I test in cannot reach packages published in the last week, so the 7.1 nightly, which is the whole point of #348, remains yours to check. If the third spelling reports TS5115 there, that settles it in favour of this PR.

Separately, and this may lift the constraint you have been honouring: this repo declares no minimum TypeScript version at all. No typesVersions, nothing in the README, and the devDependency is ^5.4.0. You picked 4.0.3 yourself, reasonably, because nothing said otherwise. I am going to declare a floor, and the consumer that matters most here, stylelint, develops against TypeScript 7. If the floor lands at 5.0 then 4.0.3 stops being a constraint on either spelling, and the exclusion can be kept.

Keep selector children typed as every node except selectors without making the recursive node union part of Container’s base type. This preserves exhaustive narrowing while avoiding the TypeScript 7.1 recursion error.
@firefoxic

Copy link
Copy Markdown
Author

I apologize, but it seems my previous answer was incorrect. I’m not sure where, but I must have made a mistake somewhere (I guess I should just sleep at night instead of experimenting) and that’s why I was getting errors. I’ve just rechecked your version—it turns out to work perfectly on all versions of TypeScript, including tsrs.

I’ve made the correction in this PR.

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.

Spell Selector without the self-reference its base type holds

2 participants