Skip to content

Breaking: Add zero-cost TypeScript node type narrowing layer#205

Merged
bartveneman merged 22 commits intomainfrom
claude/css-node-subtypes-FBg3l
Apr 3, 2026
Merged

Breaking: Add zero-cost TypeScript node type narrowing layer#205
bartveneman merged 22 commits intomainfrom
claude/css-node-subtypes-FBg3l

Conversation

@bartveneman
Copy link
Copy Markdown
Member

Summary

This PR introduces a comprehensive TypeScript type narrowing system for CSS AST nodes, enabling type-safe node handling without runtime overhead. The implementation provides both type predicate functions and a discriminated union type for exhaustive narrowing patterns.

Key Changes

  • New src/node-types.ts: Defines 39 specialized node interfaces that extend CSSNode with precise property types, removing spurious undefined values from type-specific properties

    • Each interface overrides property types to reflect what a specific node type actually returns
    • Examples: DeclarationNode.property is string (not string | undefined), DimensionNode.unit is string (not string | undefined)
  • Type predicate functions: 39 individual is_*() functions for runtime type narrowing

    • Each compiles to a single integer comparison (zero heap allocation)
    • All are individually tree-shakeable named exports
    • Enable idiomatic TypeScript patterns: if (is_declaration(node)) { node.property /* string */ }
  • AnyCssNode discriminated union: Enables exhaustive switch-based narrowing without explicit type predicates

    • Allows automatic type narrowing in switch statements based on node.type
    • Useful for walk callbacks and visitor patterns
  • Comprehensive test suite (src/node-types.test.ts):

    • Runtime behavior tests for all major type predicates
    • Compile-time type assertion tests using expectTypeOf to verify narrowing works correctly
    • Tests for selector subtypes and complex nested structures
  • Updated exports in src/index.ts: All node type interfaces and predicate functions are now publicly exported

  • Updated src/walk.ts: Walk callback parameter type changed from CSSNode to AnyCssNode for better type inference in user code

  • Updated src/constants.ts: Added missing UNICODE_RANGE export

Implementation Details

  • All interfaces are erased at compile time — zero bytes in the JS output
  • Type predicates compile to single integer comparisons with no runtime overhead
  • The design follows TypeScript best practices for discriminated unions and type guards
  • Comprehensive documentation in comments explains usage patterns and benefits

https://claude.ai/code/session_01AutHjiFtdrfyBLJ64JwXnM

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 1, 2026

Bundle Report

Changes will increase total bundle size by 14.63kB (8.84%) ⬆️⚠️, exceeding the configured threshold of 5%.

Bundle name Size Change
@projectwallace/css-parser-esm 180.17kB 14.63kB (8.84%) ⬆️⚠️

Affected Assets, Files, and Routes:

view changes for bundle: @projectwallace/css-parser-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
node-types-Dt096ytO.d.ts (New) 21.37kB 21.37kB 100.0% 🚀
parse-atrule-prelude.js -146 bytes 20.95kB -0.69%
css-node-CbNQbD4P.js (New) 14.15kB 14.15kB 100.0% 🚀
index.d.ts 2.52kB 8.83kB 39.91% ⚠️
arena-CveFYH2l.js (New) 7.14kB 7.14kB 100.0% 🚀
index.js 2.63kB 6.47kB 68.27% ⚠️
arena-D6TANE5e.d.ts (New) 4.31kB 4.31kB 100.0% 🚀
constants.d.ts -475 bytes 2.6kB -15.43%
constants.js -485 bytes 2.18kB -18.18%
parse-atrule-prelude.d.ts 2 bytes 1.69kB 0.12%
walk.d.ts 16 bytes 1.51kB 1.07%
parse-selector.d.ts 12 bytes 1.48kB 0.82%
parse.d.ts 11 bytes 1.3kB 0.85%
parse-value.d.ts -2 bytes 985 bytes -0.2%
parse-declaration.d.ts 10 bytes 885 bytes 1.14%
parse-anplusb.d.ts 10 bytes 780 bytes 1.3%
css-node-CbTZaAr8.js (Deleted) -14.55kB 0 bytes -100.0% 🗑️
css-node-DMEr3QHT.d.ts (Deleted) -9.25kB 0 bytes -100.0% 🗑️
arena-Dnn-sJmP.js (Deleted) -7.73kB 0 bytes -100.0% 🗑️
arena-B7tnQKoV.d.ts (Deleted) -4.91kB 0 bytes -100.0% 🗑️

Files in parse-atrule-prelude.js:

  • ./src/parse-atrule-prelude.ts → Total Size: 20.51kB

Files in css-node-CbNQbD4P.js:

  • ./src/css-node.ts → Total Size: 13.94kB

Files in arena-CveFYH2l.js:

  • ./src/arena.ts → Total Size: 6.38kB

Files in index.js:

  • ./src/node-types.ts → Total Size: 2.56kB

  • ./src/index.ts → Total Size: 0 bytes

Files in constants.js:

  • ./src/constants.ts → Total Size: 800 bytes

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 1, 2026

Codecov Report

❌ Patch coverage is 46.61017% with 63 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.50%. Comparing base (4064188) to head (465fcb7).

Files with missing lines Patch % Lines
src/node-types.ts 38.09% 52 Missing ⚠️
src/css-node.ts 41.66% 7 Missing ⚠️
src/parse-anplusb.ts 0.00% 2 Missing ⚠️
src/parse-value.ts 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #205      +/-   ##
==========================================
- Coverage   95.41%   93.50%   -1.92%     
==========================================
  Files          16       17       +1     
  Lines        2878     2939      +61     
  Branches      814      810       -4     
==========================================
+ Hits         2746     2748       +2     
- Misses        132      191      +59     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

claude added 11 commits April 2, 2026 11:59
Introduces a zero-cost type-narrowing layer so consumers get precise
property types per node kind instead of wide `string | undefined` unions.

- src/node-types.ts: 40+ interfaces extending CSSNode with narrowed
  property types (e.g. DeclarationNode.property: string, not string |
  undefined; DimensionNode.unit: string; BlockNode.is_empty: boolean),
  an AnyCssNode discriminated union for switch-based narrowing, and 41
  is_* type predicate functions (each compiles to a single integer
  comparison — zero runtime overhead)
- src/walk.ts: WalkCallback and WalkEnterLeaveCallback parameter types
  updated to AnyCssNode, enabling automatic narrowing inside callbacks
- src/index.ts: exports all new node subtypes and predicates
- src/constants.ts: fix missing UNICODE_RANGE re-export
- src/node-types.test.ts: runtime and compile-time (expectTypeOf) tests

https://claude.ai/code/session_01AutHjiFtdrfyBLJ64JwXnM
- Add UNICODE_RANGE to CSSNodeType union in css-node.ts (it was defined
  in arena.ts but omitted from the union, making the UnicodeRangeNode
  interface and its predicate fail to typecheck)
- Remove erroneous second argument from parse_declaration() calls in
  tests (the standalone function takes only one argument; values are
  always parsed)

https://claude.ai/code/session_01AutHjiFtdrfyBLJ64JwXnM
Replace `extends CSSNode` with `extends CssNodeCommon`, an internal
interface containing only properties universal to every node. This
removes irrelevant getters from autocomplete entirely — a DeclarationNode
no longer shows `unit`, `attr_operator`, `nth_a`, etc.

Key changes:
- All subtypes now extend CssNodeCommon (not CSSNode), so only the
  properties declared on each subtype appear in editor autocomplete
- CSSNode structurally satisfies CssNodeCommon, so type predicates
  accepting CssNodeCommon still work with plain CSSNode at call sites
- Cross-references use specific subtypes: RuleNode.prelude is
  SelectorListNode|SelectorNode|null, AtruleNode.prelude is
  AtrulePreludeNode|RawNode|null, DeclarationNode.value is
  ValueNode|string|null, NthOfSelectorNode.nth/selector are typed, etc.
- Merge the two export blocks in index.ts into one using inline type
  modifiers (export { type X, Y } from './node-types')

https://claude.ai/code/session_01AutHjiFtdrfyBLJ64JwXnM
…rNode

The An+B values (nth_a/nth_b) are properties of the NTH_SELECTOR child
node, not the NTH_OF_SELECTOR wrapper. Access them via node.nth.nth_a
instead of directly on NthOfSelectorNode.

https://claude.ai/code/session_01AutHjiFtdrfyBLJ64JwXnM
… walkers

- `parse()` returns `StyleSheetNode` instead of `CSSNode`
- `parse_selector()` returns `SelectorListNode`
- `parse_declaration()` returns `DeclarationNode`
- `parse_value()` returns `ValueNode`
- `parse_anplusb()` returns `NthSelectorNode | null`
- `parse_atrule_prelude()` returns `AnyCssNode[]`
- `walk()` and `traverse()` now accept `CssNodeCommon` (any subtype)
- Export `CssNodeCommon` from public API
- Add missing typed properties to several node interfaces:
  `UniversalSelectorNode.name`, `FunctionNode.value`, `OperatorNode.value`,
  `UrlNode.name/value`, `MediaTypeNode.value`, `FeatureRangeNode.name`,
  `SupportsQueryNode.value`, `LayerNameNode.value`, `PreludeSelectorListNode`
- Add `PRELUDE_SELECTORLIST` to `CSSNodeType` union
- Update all test files with appropriate type casts

https://claude.ai/code/session_01AutHjiFtdrfyBLJ64JwXnM
StyleSheetNode → StyleSheet, RuleNode → Rule, AtruleNode → Atrule,
DeclarationNode → Declaration, SelectorNode → Selector, etc.

Also renames AnyCssNode → AnyCss.

https://claude.ai/code/session_01AutHjiFtdrfyBLJ64JwXnM
- Rule.prelude: SelectorList | Selector | null → SelectorList | Raw | null
  (parse_selectors=false yields RAW, not SELECTOR)
- Declaration.value: Value | string | null → Value | Raw | null
  (parse_values=false yields RAW node, not raw string)
- Remove value_as_number from Number, Dimension, and CSSNode
  (.value already gives the number; value_as_number was redundant)
- Remove Url.name (always "url" — trivially obvious, not useful)
- PseudoClassSelector: remove selector_list property and CSSNode getter;
  the selector list child is accessible via inherited children/first_child
- Combinator.name: clarify it contains " ", ">", "~", "+", "||", "/deep/"
- UniversalSelector.name: undefined → null
- NthOfSelector.nth/selector: undefined → null

https://claude.ai/code/session_01AutHjiFtdrfyBLJ64JwXnM
LAYER_NAME only needs one string field (the layer name text). Both code
paths now write to content only, and the value getter aliases get_content()
for LAYER_NAME nodes — eliminating 2 unnecessary arena writes per node.
Each subtype interface now overrides clone() to return ToPlain<T>, which is
PlainCSSNode & { type: T['type'] } & { subtype-specific properties }. This
means calling .clone() on a narrowed node type gives back a plain object with
only the relevant fields typed — no explicit 'as PlainCSSNode' casts needed.

AttributeSelector.clone() uses a manually-written return type because
attr_operator/attr_flags are stored as numbers on the live node but serialised
as strings by clone(). UniversalSelector.name is changed from string|null to
string|undefined for consistency with PlainCSSNode's string-field convention.

Zero runtime overhead — the change is purely at the TypeScript type level.
…s change

attr_operator and attr_flags are now derived from source text on demand and
return string | null (not numbers). Update the AttributeSelector interface,
its clone() return type (can now use ToPlain<AttributeSelector> directly),
and the type assertion in node-types.test.ts.

Also add missing as AttributeSelector casts in parse-selector.test.ts where
first_child accesses needed narrowing.
@bartveneman bartveneman force-pushed the claude/css-node-subtypes-FBg3l branch from b5bdb55 to e835106 Compare April 2, 2026 12:02
claude added 2 commits April 2, 2026 13:52
- Remove unused type imports (AnyCss, Function, Rule, Selector, SelectorList)
  from parse.test.ts, parse-selector.test.ts, parse-value.test.ts,
  node-types.test.ts
- Prefix unused variable 'declaration' with '_' in api.test.ts
- Fix no-unsafe-optional-chaining: change decl?./func?./paren?. to !. where
  the surrounding test already asserts the value is present
- Export PreludeSelectorList type and is_prelude_selectorlist predicate from
  index.ts (were defined but never re-exported, causing knip failure)
- Add explanatory comment to PreludeSelectorList: it's the parenthesised
  selector argument in @scope preludes; distinct from SELECTOR_LIST so that
  walkers can tell "scoping selector" from "rule selector" without ambiguity
@bartveneman bartveneman changed the title Add zero-cost TypeScript node type narrowing layer Breaking: Add zero-cost TypeScript node type narrowing layer Apr 2, 2026
claude and others added 9 commits April 2, 2026 17:06
…ctor

- Add Parenthesis, ContainerQuery, MediaFeature, FeatureRange, and
  PseudoElementSelector to WithChildren (all have children in the parser)
- Update test files to cast first_child/first_child nodes to their proper
  WithChildren types (Value, Selector, SelectorList, Block, AtrulePrelude,
  MediaQuery, Function, Parenthesis) instead of accessing .children on
  CssNodeCommon directly
- Replace Atrule.has_children with has_prelude || has_block (Atrule is not
  a container node)
- Replace CssNodeCommon.has_children with first_child !== null where needed
- Fix Url.has_children tests to use first_child check (Url has no children)
- Fix api.test.ts iteration test to use atrule.prelude property directly

https://claude.ai/code/session_01AutHjiFtdrfyBLJ64JwXnM
@bartveneman bartveneman merged commit c375f50 into main Apr 3, 2026
4 of 5 checks passed
@bartveneman bartveneman deleted the claude/css-node-subtypes-FBg3l branch April 3, 2026 15:03
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