Skip to content

[docs-infra] Render preserved keyof type operators - #1817

Open
Janpot wants to merge 9 commits into
masterfrom
worktree-docs-infra-keyof-type-operator
Open

[docs-infra] Render preserved keyof type operators#1817
Janpot wants to merge 9 commits into
masterfrom
worktree-docs-infra-keyof-type-operator

Conversation

@Janpot

@Janpot Janpot commented Aug 26, 2026

Copy link
Copy Markdown
Member

typescript-api-extractor 1.0.0-beta.6 (#1804, shipped in canary.34) stopped expanding authored keyof and now emits typeOperator / typeQuery nodes. The formatters dispatch on kind and fell through to a bare unknown, so every keyof type documented as the literal word unknown — in Base UI, Field.Error's match became boolean | unknown (which TypeScript reduces to unknown, so the table claimed any value was accepted) and useRender's defaultTagName lost its 178-member union.

The node still carries the checker result, so this formats that and keeps the previous output. A type parameter operand stays as keyof T in raw declarations, since its resolved base constraint holds no T to recover.

Also here, found while fixing the above:

  • formatExternalTypeDefinition had the same fallthrough, and its all-members-are-literals gate silently dropped external unions containing a keyof from the External Types section entirely.
  • A keyof member of a union skipped dedup, so 'size' | keyof Config documented as 'size' | 'size' | 'color'.
  • Union members joined into an intersection were not grouped, so A | B & C read as A | (B & C). Pre-existing and not keyof-specific — it corrects Base UI's combobox data param today.
  • An unhandled node kind now fails pnpm typescript instead of documenting itself as unknown.

Verified against Base UI at upstream master: mui/base-ui#5580.

typescript-api-extractor 1.0.0-beta.6 stopped expanding authored `keyof`
into a literal union and instead emits `typeOperator` and `typeQuery`
nodes. formatType dispatches on `kind` and ends in a bare `return
'unknown'`, so every `keyof` type documented as the literal word
`unknown`.

In Base UI this collapsed `Field.Error`'s `match` to `boolean | unknown`
(which TypeScript reduces to `unknown`, so the table claimed any value
was accepted) and `useRender`'s `defaultTagName` to `unknown`.

The operator node still carries the checker result in `resolvedType`, so
format that and keep the previous output. No length-dependent behaviour:
sizing the output to the type would be inconsistent with the rest of the
formatter.
@code-infra-dashboard

code-infra-dashboard Bot commented Aug 26, 2026

Copy link
Copy Markdown

Deploy preview

https://deploy-preview-1817--mui-internal.netlify.app/
QR code for https://deploy-preview-1817--mui-internal.netlify.app/

Bundle size

Total Size Change: 0B(0.00%) - Total Gzip Change: 0B(0.00%)
Files: 71 total (0 added, 0 removed, 0 changed)

Show details for 71 more bundles

@mui/internal-docs-infra/abstractCreateDemoparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/abstractCreateDemoClientparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/abstractCreateStreamparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/abstractCreateTypesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/ChunkProviderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/cliparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CodeControllerContextparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CodeExternalsContextparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CodeHighlighterparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CodeHighlighter/errorsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CodeHighlighter/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CodeProviderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CoordinatedLazyparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CoordinatedLazy/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/createDemoDataparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/createDemoData/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/createSitemapparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/createSitemap/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useCodeparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useCodeWindowparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useCoordinatedparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useCopierparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useCrossTabStateparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useDemoparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useDemoControllerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useErrorsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useLocalStorageStateparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/usePreferenceparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useScrollAnchorparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useSearchparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useSearch/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useStreamparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useStream/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useTypeparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useTypesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useUrlHashStateparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/withDocsInfraparsed: 0B(0.00%) gzip: 0B(0.00%)
addLineGuttersparsed: 0B(0.00%) gzip: 0B(0.00%)
BuildEngineparsed: 0B(0.00%) gzip: 0B(0.00%)
CodeHighlighterChunkparsed: 0B(0.00%) gzip: 0B(0.00%)
CodeHighlighterClientparsed: 0B(0.00%) gzip: 0B(0.00%)
CodeInitialSourceLoaderparsed: 0B(0.00%) gzip: 0B(0.00%)
CodeSourceLoaderparsed: 0B(0.00%) gzip: 0B(0.00%)
compileCssWithPostcssparsed: 0B(0.00%) gzip: 0B(0.00%)
computeHastDeltasparsed: 0B(0.00%) gzip: 0B(0.00%)
createFrameparsed: 0B(0.00%) gzip: 0B(0.00%)
createParseSourceWorkerClientparsed: 0B(0.00%) gzip: 0B(0.00%)
EditingEngineparsed: 0B(0.00%) gzip: 0B(0.00%)
embedTransformsparsed: 0B(0.00%) gzip: 0B(0.00%)
enhanceCodeEmphasisparsed: 0B(0.00%) gzip: 0B(0.00%)
findExpandingRangesparsed: 0B(0.00%) gzip: 0B(0.00%)
getHastTextContentparsed: 0B(0.00%) gzip: 0B(0.00%)
grammarLoadersparsed: 0B(0.00%) gzip: 0B(0.00%)
grammarsparsed: 0B(0.00%) gzip: 0B(0.00%)
isFrameSpanparsed: 0B(0.00%) gzip: 0B(0.00%)
loadCodeFallbackparsed: 0B(0.00%) gzip: 0B(0.00%)
loadIsomorphicCodeVariantparsed: 0B(0.00%) gzip: 0B(0.00%)
parseSourceparsed: 0B(0.00%) gzip: 0B(0.00%)
rolldown-runtimeparsed: 0B(0.00%) gzip: 0B(0.00%)
source.cssparsed: 0B(0.00%) gzip: 0B(0.00%)
source.jsparsed: 0B(0.00%) gzip: 0B(0.00%)
source.jsonparsed: 0B(0.00%) gzip: 0B(0.00%)
source.mdxparsed: 0B(0.00%) gzip: 0B(0.00%)
source.shellparsed: 0B(0.00%) gzip: 0B(0.00%)
source.tsparsed: 0B(0.00%) gzip: 0B(0.00%)
source.tsxparsed: 0B(0.00%) gzip: 0B(0.00%)
source.yamlparsed: 0B(0.00%) gzip: 0B(0.00%)
text.html.basicparsed: 0B(0.00%) gzip: 0B(0.00%)
text.mdparsed: 0B(0.00%) gzip: 0B(0.00%)
TransformEngineparsed: 0B(0.00%) gzip: 0B(0.00%)
transpileSourceparsed: 0B(0.00%) gzip: 0B(0.00%)

Details of bundle changes

Performance

Total duration: 16.96 ms -2.75 ms(-13.9%) | Renders: 5 (+0) | Paint: 58.71 ms -13.83 ms(-19.1%)

Test Duration Renders
HeavyList mount 8.86 ms ▼-3.11 ms(-26.0%) 1 (+0)
sub-series via labels 0.00 ms 0 (+0)

5 tests within noise — details

Metric alarms

Test Metric Change
sub-series via labels fib_phase#small 🔺 +0.01 ms

Check out the code infra dashboard for more information about this PR.

@Janpot Janpot added the scope: docs-infra Involves the docs-infra product (https://www.notion.so/mui-org/b9f676062eb94747b6768209f7751305). label Aug 26, 2026
Hand-built nodes assert whatever shape the test author imagined, so they
cannot catch typescript-api-extractor changing its output — the exact
class of break they were added for.

Extract the in-memory parser harness from transformConstantGroup.test
into parseTestSources and drive these cases from real source instead.
The helper gains a virtual default library, which is what makes the
parser preserve `keyof`: an operand it can expand, such as a locally
declared interface, never reaches the formatter as an operator.

Also drop the named-alias branch. Both TypeOperatorNode construction
sites in the parser pass `undefined` for the type name, so the branch
was unreachable and its test asserted behaviour the parser does not
produce. The alias name arrives on the resolved union instead.
@Janpot
Janpot requested a review from a team August 26, 2026 14:47
@Janpot
Janpot marked this pull request as ready for review August 26, 2026 14:47
@Janpot

Janpot commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

@claude review

@github-actions

This comment was marked as resolved.

Keep a type parameter operand by name in raw declarations. The checker
resolves `keyof T` to its base constraint, which holds no type parameter
to recover `T` from, so a documented `Box<T>` rendered `string | number |
symbol` with the declared `T` left unused. Concrete operands still
resolve, so `keyof React.JSX.IntrinsicElements` keeps documenting its key
set rather than collapsing to the authored syntax.

Share the extraction policy through constants.ts. The test harness only
passed `includeExternalTypes`, while the pipeline also caps depth and
property count — and those caps are what decide whether an operand is
resolved at all, so a `keyof` over a wide interface could have resolved
in tests and stayed unresolved in the real build.

Rename the harness to parseSources.testUtils.ts, matching
coordinatePreference.testUtils.ts. The package ships raw TypeScript from
src, so a fixture builder sitting next to processTypes.ts under a `parse`
purpose prefix reads as a supported entry point. Declarations for
testUtils siblings are excluded from the build; the transpiled output
still ships because that ignore list lives in code-infra.
Janpot added 5 commits August 26, 2026 18:07
Fail the build on an unhandled node kind. `formatType` ended in a silent
`return 'unknown'`, which is how preserved `keyof` operators shipped as
the literal word `unknown` through a parser upgrade. The kinds that
legitimately fall through are the export-level ones, so naming them
leaves a `never` assertion that turns the next parser addition into a
type error instead of a quietly wrong props table.

Use PARSER_OPTIONS at its call sites rather than re-aliasing it, and
switch the last hand-rolled copy of the extraction policy over to it.

Revert the testUtils rename and its build exclusion. How shared test
helpers should be named and kept out of the published package is its own
question, not one to settle inside a regression fix.
`formatExternalTypeDefinition` is a second kind dispatcher with its own
silent `unknown` tail, so the External Types section did not inherit the
formatType fix.

Two regressions against the pre-upgrade output. An external union with a
`keyof` member failed the all-members-are-literals gate and was dropped
from the section entirely, leaving the props table naming a type whose
definition appeared nowhere on the page. An external function type with a
`keyof` parameter documented as `(tone: unknown) => string`.

Resolve operator members before both the gate and the formatting, so
these read as the keys they stand for. Flattening ahead of the existing
`uniq` also means a key already listed alongside the operator is no
longer repeated.
`'size' | keyof Config` documented as `'size' | 'size' | 'color'`. The
operator resolved to its keys as one finished string, after the union had
already flattened, ordered and deduped its members, so those keys never
took part.

Flatten them alongside the other members instead. Whether an operator
expands at all is now asked in one place, so the union branch cannot
disagree with the operator branch and start flattening a `keyof T` that
is being kept by name.
`&` binds tighter than `|`, so a union member joined into an intersection
loses its grouping: `keyof Config & Meta` documented as `'size' | 'color'
& Meta`, which reads as `'size' | ('color' & Meta)`.

Wrap a member that reads as a union at its top level. Pipes nested inside
a member are already grouped by their own brackets and are left alone, so
`{ x: string | number } & Meta` is untouched. Intersections inside a union
need nothing, since those already bind the way they read.

This is not specific to preserved operators: in Base UI it corrects
`Item[] & RejectGroupShapedItems<Item>`, whose conditional type resolves
to a union.
Group a composite operand in the authored-syntax fallback. `keyof (A | B)`
rendered as `keyof A | B`, which reads as `(keyof A) | B` — the same
precedence bug the intersection join had, at a site this branch added.

Move the grouping rule into its own module so both formatters apply it
rather than each spelling it at a call site, and generalize it to the
operator being bound against.

Use the package's `satisfies` idiom for the exhaustiveness assert. It
names the missing kind in the error instead of just reporting `never`,
and leaves the fallback returning a string rather than a node.

Drive the remaining fixtures from source. The hand-built external nodes
claimed a shape the parser never produces here, since this section always
expands.
@Janpot
Janpot requested a review from jjenzz August 28, 2026 08:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: docs-infra Involves the docs-infra product (https://www.notion.so/mui-org/b9f676062eb94747b6768209f7751305).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant