Breaking: Add zero-cost TypeScript node type narrowing layer#205
Merged
bartveneman merged 22 commits intomainfrom Apr 3, 2026
Merged
Breaking: Add zero-cost TypeScript node type narrowing layer#205bartveneman merged 22 commits intomainfrom
bartveneman merged 22 commits intomainfrom
Conversation
Bundle ReportChanges will increase total bundle size by 14.63kB (8.84%) ⬆️
Affected Assets, Files, and Routes:view changes for bundle: @projectwallace/css-parser-esmAssets Changed:
Files in
Files in
Files in
Files in
Files in
|
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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.
b5bdb55 to
e835106
Compare
- 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
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 extendCSSNodewith precise property types, removing spuriousundefinedvalues from type-specific propertiesDeclarationNode.propertyisstring(notstring | undefined),DimensionNode.unitisstring(notstring | undefined)Type predicate functions: 39 individual
is_*()functions for runtime type narrowingif (is_declaration(node)) { node.property /* string */ }AnyCssNodediscriminated union: Enables exhaustive switch-based narrowing without explicit type predicatesnode.typeComprehensive test suite (
src/node-types.test.ts):expectTypeOfto verify narrowing works correctlyUpdated exports in
src/index.ts: All node type interfaces and predicate functions are now publicly exportedUpdated
src/walk.ts: Walk callback parameter type changed fromCSSNodetoAnyCssNodefor better type inference in user codeUpdated
src/constants.ts: Added missingUNICODE_RANGEexportImplementation Details
https://claude.ai/code/session_01AutHjiFtdrfyBLJ64JwXnM