Skip to content

feat(cli): read two written TypeScript types as one when they differ only in how they are written - #7684

Merged
josephfarina merged 2 commits into
mainfrom
josephfarina/doctor-type-text
Oct 19, 2026
Merged

josephfarina merged 2 commits into
mainfrom
josephfarina/doctor-type-text

Conversation

@josephfarina

@josephfarina josephfarina commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

User need

astryx doctor integration components judges each documented prop's type against the code (#7667). A doc and the source often write one type in different ways: quotes, the order of a union's members, Array<T> or T[], a callback's parameter names, a React. qualifier. Compared as written, each of those would be reported as a mismatch where there is none.

Problem and solution fit

This PR adds the reading that makes two written types compare as one type when they differ only in how they are written. It is foundation groundwork, on #7676, and #7667, stacked on it, judges prop types with it. They were one PR until fixes took #7667 past 2,000 lines, and it split along this seam: type-text.mjs and its tests moved here unchanged. The judge that compares a documented type with the code's, judgeType in type-match.mjs, followed later for the same reason, also unchanged; #7667 wires it into the comparison.

normalizeTypeText(text, {ts, optional}) parses the text with the project's own TypeScript and prints the type canonically, so grouping always comes from the parse. It returns the members of the type's top-level union, sorted. It merges only what TypeScript reads as one type:

  • comments, whitespace, parentheses that change nothing, and quote style;
  • the names of function and tuple parameters: a this parameter is kept, and a type predicate names its parameter by place;
  • the order of a union's members and of an object type's properties, but not of overloads or an intersection's members;
  • Array<T> and T[], and a React. qualifier;
  • true | false and boolean, a string, number or boolean literal beside its primitive, never among other union members, and, with optional, undefined.

It sets readonly, Readonly<…> and ReadonlyArray<…> aside; #7667 compares text that writes readonly exactly instead. Below the top of a type, a function or conditional type always prints in parentheses, and a union or intersection wherever its members would otherwise bind differently. So joining the members with | can never read as one function. Text that doesn't parse as one type, an import type with attributes, and any form the printer doesn't know are kept as written (comments and whitespace aside), so none reads as another type.

Current specification

Supports architecture:cli-surface INV33, "Documented component props agree with the source" (AST-039 FR12, which now points to it; DEC-5), with #7681, #7663, #7676 and #7667. Its evidence row names the type-text tests.

Public delta and caller burden

Design and alternatives

  • Spelling only. The reading removes differences that leave the type the same. It decides nothing about what TypeScript accepts: readonly is set aside here, and feat(cli): doctor integration components judges documented prop types against the code #7667 compares text that writes it exactly.
  • Parsed, then printed. It reads the text with the TypeScript the comparison already loads, the project's own, so it adds no dependency. Grouping, precedence and string literals come from the parse, not from rewriting the string.
  • Rejected: rewriting the string with patterns, which this PR did until its third review. Each round found two different types it read as one, because grouping had to be rebuilt by hand.

Evidence

  • foundation/discovery/type-text.test.mjs:
    • each rule above as two spellings read as one type (32 cases): quotes and union order, React. qualifiers, Array<T> (of a callback too), ReadonlyArray<…>, readonly (before a group or a tuple too), Readonly<…>, true | false, a literal beside its primitive, parameter names, object member order and separators, comments, whitespace, never in a union, a union around a function in either order, a parenthesized return, a this parameter, a type predicate with renamed parameters (also before a string literal), renamed optional and tuple parameters, a quoted property name, a number written two ways, a parenthesized true branch, nested unions, and Readonly<…> of an intersection array, an object type and a Record;
    • undefined dropped only on an optional prop;
    • different types kept different (33 pairs, and the same joined as the judgment joins union members): an array of callbacks and a callback returning an array (also as TypeScript prints them), an array of an intersection and an intersection with an array, (keyof T)[] and keyof T[], keyof (A & B) and keyof A & B, never as a whole type (a parameter, a tuple member, an array element), string literals that contain ;, readonly or React., a union around a function and one in its return (alone, in an array and in an object), a this parameter and an ordinary one, a conditional whose false branch takes a union, an array of Readonly<A & B> and an intersection with an array, Array<keyof (B)> and keyof (Array<B>), type predicates on different parameters (also before a string literal), a function in a conditional's true branch and one that returns the rest, Readonly<A | B> & C and A | (B & C), keyof Readonly<A & B> and (keyof A) & B, overloads in another order, text that doesn't parse, a computed property name and an index signature, import types whose attributes differ, and a method named 'new' beside a construct signature.
  • foundation/discovery/type-match.test.mjs: judgeType's verdicts in one table: exact parts in any order and a name for them, matched; a literal left out, a wrong primitive, another enum of the same values, and a literal union for an enum, reported; a mutable array for a readonly one, matched, and a readonly one for a mutable array, reported; null on both sides; a callback written as the source writes it, matched, and written another way, not compared; a string literal holding '|', not read as two; a string enum member beside its own value's literal, folded away; readonly inside a string literal, read as a word; React.X read through React, so React.KeyboardEvent beside the DOM's KeyboardEvent is not a match and React.Key is React's even where the file declares its own Key; a union member another covers (string[] beside (string | number)[], an array beside object, undefined beside void), folded, while a readonly array beside a mutable one is not; an enum value holding an unpaired surrogate, judged without throwing; and prose, not compared.
  • feat(cli): doctor integration components judges documented prop types against the code #7667's tests use both through the prop comparison.

Since it opened

  • The first review found it reading different types as the same, which would let feat(cli): doctor integration components judges documented prop types against the code #7667 call a wrong documented type a match. It came from feat(cli): doctor integration components judges documented prop types against the code #7667 unchanged, so these predate the split:
    • A one-member group lost its parentheses, so (() => void)[] read like () => void[], and the same for (A & B)[] and (keyof T)[]; Array<…> had the same gap. Parentheses stay now around a member that isn't a primary type, in both places, which also makes Array<() => void> match (() => void)[].
    • never was dropped outside unions, so (x: never) => void read like () => void, and [never] and Array<never> like []. It's dropped only from a union with other members now.
    • The ;, React. and readonly rewrites reached inside string literals, so 'x;y' read like 'x,y'. Quoted text is left as written now.
    • readonly ("a" | "b")[], TypeScript's print of ReadonlyArray<'a' | 'b'>, kept its readonly. It's set aside now like readonly string[].
  • An independent audit then fuzzed it with about 31,000 type texts against tsc's assignability, and found three more ways it read different types as one, each fixed with a test that failed first:
    • A this parameter lost its name like any other, so (this: Window, e: Event) => void read like (w: Window, e: Event) => void. It keeps it now.
    • A union around a function and a union in its return joined into the same text when the return's members sorted first, so (() => ReactNode) | null read like () => ReactNode | null, alone and inside arrays, objects and generic arguments. A function or a conditional among other union members keeps its parentheses now.
    • A conditional's false branch was split at its first |, so C ? A : B | D read like (C ? A : B) | D. A conditional at the top of a type is one member now, as TypeScript reads it.
    • What else it found involves readonly, which feat(cli): doctor integration components judges documented prop types against the code #7667 leaves out of the text match.
  • The second review approved it and listed three more distinct types it read as one, fixed here with tests that failed first:
    • Readonly<A & B>[] read like A & B[]: Readonly<…> dropped its parentheses without the check the group branch uses. It keeps them before [] now, and an object type or a Record inside Readonly<…> reads as itself.
    • Array<keyof (B)> and keyof (Array<B>) both read as keyofB[]: dropping the parentheses glued keyof to the name. A word before the parentheses stays apart from the word inside now.
    • A type predicate kept its parameter's name while the names were set aside, so predicates on different parameters read alike. The predicate names the parameter by its place now.
  • On Core and Lab, feat(cli): doctor integration components judges documented prop types against the code #7667 judges the same types with all of these fixes.
  • The third review found two more: a function in a conditional's true branch took the false branch into its return type, and a type predicate before a string literal skipped the by-place rewrite; Readonly<A | B> & C and keyof Readonly<A & B> also lost their grouping. Since each round found more of these, the reading was rebuilt on the parser, as described above, instead of patched again. The tests keep every earlier pair and add these. On Core and Lab, feat(cli): doctor integration components judges documented prop types against the code #7667 judges exactly the same types, with the same findings and messages.
  • An independent audit fuzzed the rebuilt reading with about 65,000 types, checked with tsc both ways. Every pair it reads as one is the same type, readonly aside, except two exotic ones, now fixed with tests that failed first: a computed property name read like an index signature ({[K]: number} and {[k: K]: number}), and import types whose attributes differ read alike. It also checked 34 pairs of identical types that print differently, such as intersection order and type-parameter names: each is a match or not compared, never a mismatch. All 66 tests pass.
  • Rebased on feat(cli): compare documented component props with the TypeScript source #7663, whose reference walk moved to feat(cli): read a package's TypeScript project the way tsc reads it #7681.

After the fourth review

  • A method named by the string 'new' printed like a construct signature, so {'new'(): X} and {new(): X} read as one type. A construct signature prints as new (…) now, with a space, which no method name can produce. The pair is in the 'keeps different' tests, and failed before. All 67 tests pass.

After the fifth review

  • Text kept as written dropped a comment without keeping the words around it apart, so {get/*c*/a(): string} (a getter) and {geta(): string} (a method) read as one type. A comment now separates two words as a space does. The pair is in the 'keeps different' tests and failed before, and {get /*c*/ a(): string} still reads as {get a(): string}. All 69 tests pass.

After the sixth review

  • An accessor kept as written kept the ; or , that ends it, so {get a(): string; b: number} and {get a(): string, b: number} read as different types, a false mismatch. The separator is dropped now. The getter and setter pairs are in the 'reads as' tests and failed before. All 71 tests pass.

After the seventh review

  • Text that does not parse came back unmarked, so text spelled the way the printer prints a type read as that type: ((Item[]) => void)[], a parse error, normalized the same as ((items: Item[]) => void)[]. Unparsed text now comes back marked with UNPARSED, a character no printed type contains, so it never equals a printed type, and feat(cli): doctor integration components judges documented prop types against the code #7667 lists such a doc as not compared. So does Foo<> or JSDoc's Array.<T>, which parse but which only the checker rejects. The pair is in the 'keeps different' tests, the other forms in a new test, and they failed before.
  • Nothing enforced the TypeScript floor. typeTextTooOld(ts) now names a project TypeScript older than 4.8, the oldest every printed form is tested on, so feat(cli): doctor integration components judges documented prop types against the code #7667 can skip the type comparison with one visible line instead of throwing. A test checks stubbed versions on both sides of 4.8. All 73 tests pass.

After the eighth review

  • The invalid-form check now covers any node with an empty type-argument list, so typeof Foo<> and import('x').Foo<> are marked unparsed like Foo<>. The JSDoc . test reads the text before < with comments removed, so the valid Array /* v1.2 */ <string> reads as string[]. Both are in the unparsed-text test and failed before.

After the round-21 review

  • JSDoc-only forms that parse but that the checker rejects (?string, !string, string?, *, string=, function(string): void) are now marked unparsed, like Array.<T>. feat(cli): doctor integration components judges documented prop types against the code #7667 lists them as not compared instead of reporting them as differing. Four of them are in the unparsed-text test, which failed before. Valid TypeScript forms such as [string?], [...string[]] and conditional types read as before, on TypeScript 4.8, 5.0 and 6.0.

After the round-22 review

  • More forms that parse but that the checker rejects now come back unparsed: a mapped type with members ({[K in keyof P]: P[K]; extra: string}, TS7061), a type member with a modifier other than readonly ({public a: string}, TS1070), and a parameter with a modifier or an initializer ((public a: string) => void, TS2369; (a: string = "x") => void, TS2371). Their valid neighbours read as before on TypeScript 4.8, 5.0 and 6.0. The four are in the unparsed-text test, which failed before.

Rebased onto current main

  • Rebased onto main 9b8b723 with the PRs below it. Nothing in this PR changed.

After the latest review

  • More forms that parse but that the checker rejects now come back unparsed: <in T> or <out T> on a function type's type parameter (TS1274), an index signature without one plain, typed parameter and a type ({[k?: string]: number}, {[a: string, b: string]: number}, TS1019/TS1096), a property with an initializer ({a: string = 'x'}, TS1247), and a trailing comma in type arguments (Foo<A,>, TS1009). <const T> and <T,> still read as valid on TypeScript 5.0 and 6.0, as tsc accepts them. Five of the forms are in the unparsed-text test, which failed before.

Restacked

  • Rebased onto the PRs below it. normalizeTypeText returned its cached array, so a caller that changed a result changed every later one. The cached result is now frozen, and a test checks that a change to one result throws and leaves the next intact; it failed before.

The type judge moves here

  • type-match.mjs, which judges a documented type against the type the code declares, moves here unchanged from feat(cli): doctor integration components judges documented prop types against the code #7667. That PR wires it into the comparison, and with the latest review it would have passed 2,000 lines. A new direct test pins its verdicts, so this PR tests the judge without the comparison. The changeset says the groundwork now covers judging, and that nothing reaches either part yet.

After the review of the type judge

  • String-literal parts were built unescaped and joined with |, so a doc of "a'|'b" matched code typed 'a' | 'b', and the reverse. Each literal part is now quoted with \ and ' escaped on both sides, and an enum member's value is encoded, so no text inside a literal reads as other parts.
  • A string enum member beside the literal of its own value stayed a separate part, so 'a' | 'b' and E | 'a' | 'b' were a mismatch either way round, though TypeScript passes the member where that literal is taken. Such a member is now folded away.
  • readonly inside a string literal was read as the modifier, so (mode: 'readonly' | 'edit') => void documented in the other order came back not compared. The readonly tests now read the text with its string literals masked.
  • Each is a new row in the verdict test, and each failed before. On Core and Lab, the comparison stacked on this PR judges the same types, with the same findings.

After the review of React. names

  • The judge read React.X as whatever a bare X names where the prop is declared, and the text reading sets the qualifier aside. So a handler typed with the DOM's KeyboardEvent, documented as (e: React.KeyboardEvent) => void, was a judged match though tsc rejects it, and a correct React.Key doc drew a mismatch where the file declares its own Key.
  • React.X now resolves through React where the prop is declared (an imported namespace or React's global one). The text match is refused when one side writes React.X and the other a bare X that names another type there; both sides writing React.X, or a bare X imported from React, still match.
  • The verdict test adds those rows, each failing before. On Core, three handlers whose docs write the DOM's MouseEvent for the code's React.MouseEvent move from judged to not compared (tsc rejects such a handler); no finding changes.

After the latest approval

  • An enum part encoded its value with encodeURIComponent, which throws for an unpaired surrogate, so judging such an enum threw instead of returning a verdict. Enum parts now encode each character by code point, which never throws and never reads as a separator.
  • A union member that another member covers stayed a part of its own, so string[] | (string | number)[] against (string | number)[], object | string[] against object, and void | undefined against void were mismatches though tsc assigns each both ways. Such a member is folded now: undefined beside void, an array beside object, and an array whose element types another array also takes. A readonly array covers a mutable one but not the reverse, as tsc reads it, so readonly string[] | (string | number)[] against (string | number)[] stays a mismatch.
  • Both are rows in the verdict test, and each failed before. Core and Lab give the same results.

Merge order

The order is required:

  1. feat(cli): read a package's TypeScript project the way tsc reads it #7681 (reading the package's TypeScript project), then feat(cli): compare documented component props with the TypeScript source #7663 (the prop comparison, stacked on feat(cli): read a package's TypeScript project the way tsc reads it #7681), then feat(cli): doctor integration components compares documented props with the code #7676 (props in doctor integration components, stacked on feat(cli): compare documented component props with the TypeScript source #7663), then feat(cli): read two written TypeScript types as one when they differ only in how they are written #7684 (reading two written types as one, stacked on feat(cli): doctor integration components compares documented props with the code #7676), then feat(cli): doctor integration components judges documented prop types against the code #7667 (prop types, stacked on feat(cli): read two written TypeScript types as one when they differ only in how they are written #7684).
  2. feat(cli): check each authored doc against its published authoring type #7664 (the doc type check), after feat(cli): read a package's TypeScript project the way tsc reads it #7681: once feat(cli): read a package's TypeScript project the way tsc reads it #7681 merges, feat(cli): check each authored doc against its published authoring type #7664 is rebased onto main, which takes out the 65 lines the two share and brings it to 1,960, and reviewed again. Then feat(cli): doctor integration validate checks each doc against its published authoring type #7673 (doc types in validate, stacked on feat(cli): check each authored doc against its published authoring type #7664).
  3. fix(cli): doc links to a component, page, or block resolve #7668 (doc links to components and templates) any time before feat(cli): integration check runs every source check for a package's build, with --strict #7665.
  4. feat(cli): integration check runs every source check for a package's build, with --strict #7665 (integration check) last, after a rebase that adds these PRs' fields and codes to its failures list. Merged earlier, it breaks main.

This PR must ship in the same release as integration check, with #7681, #7663, #7676 and #7667.

Overlap with open PRs

Size

1,690 changed lines.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

The CI test jobs run only for pull requests into main, so this PR's tests run in CI once it is retargeted after #7676 merges. On main 9b8b723 with #7681, #7663, #7676 and this branch: copyright, sync-exports, the published ./api type check, readme:check, the four CLI typechecks (strict, json-api, authoring-contract, template-docs), check:changesets, the lint job's changeset coverage check (run on the merge commit GitHub builds), check:cli-structure, check:repo, eslint on the changed files, prettier on the new files, and the full pnpm vitest run --project node suite: 10,396 tests pass. The 21 failures are in the npm pack, packed real-world package, theme resolution, CSS graph and browser accessibility tests, which fail the same way on main 9b8b723 on the same machine (they need a package registry and a browser it doesn't reach), plus a packed-consumer test that passes on its own in a full checkout. Earlier in review, with all nine PRs merged into main, the four doctor integration subcommands were run in text and JSON on 64 package shapes, against main: templates output is byte-identical everywhere; validate and components only add their new section and JSON field; docs differs only where the doc-links PR fixes a link warning. integration check exits 1 exactly when one of the four would, each of its sections is byte-identical to that subcommand's output, and only integration check's three list tests fail until its planned rebase.

@vercel

vercel Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 19, 2026 11:02pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🔄 In progress (for maintainers only) Oct 19, 2026, 11:03 PM UTC

@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

No new or modified components detected.

Bundle Size Summary

No component packages changed.

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

40 of 482 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 754,077
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,188
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
RichTextEditor Markdown parity: side by side probe dark 94,866
RichTextEditor Markdown parity: side by side probe light 89,762
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232
ProgressBar With Target Mark probe dark 7,895
ProgressBar With Target Mark probe light 7,895
Markdown Default neutral dark 7,573
Markdown Default neutral light 7,566

and 20 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested: normalizeTypeText currently reads some different types as the same type, which would let the stacked prop-type check (#7667) report a wrong documented type as matching the code. A one-member parenthesized group loses its parentheses, so (() => void)[] and () => void[] normalize identically (as does TypeScript's own print ((value: string) => string | undefined)[] against (value: string) => string | undefined[]), and so do (A & B)[] / A & B[] and (keyof T)[] / keyof T[]; never is also dropped outside unions, and ;, React. and readonly are rewritten inside string literals. Please keep the parentheses when the grouped member is not a primary type (and apply the same check in arrayOf), drop never only from unions with other members, leave string literals untouched, and add these pairs to the "keeps different types different" test. The same change should make Array<() => void> match (() => void)[], and readonly ("a" | "b")[] should be set aside like readonly string[].

[Automated review]

Comment on lines +235 to +237
const members = unionMembers(inner, false);
out +=
members.length > 1 ? `(${members.join('|')})` : (members[0] ?? '()');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A one-member group loses its parentheses here, so (() => void)[] and () => void[] (an array of callbacks and a callback returning an array) both normalize to ()=>void[]; (A & B)[] / A & B[] and (keyof T)[] / keyof T[] collapse the same way. TypeScript itself prints Array<(value: string) => string | undefined> as ((value: string) => string | undefined)[], so real inputs take this path. Keep the parentheses when the member is not a primary type (a top-level =>, & or conditional, or a leading keyof, typeof, unique or infer).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1854a0a. A one-member group keeps its parentheses when the member isn't a primary type: a top-level =>, &, | or conditional, or a leading keyof, typeof, unique or infer. (() => void)[] and () => void[], the same pair as TypeScript prints it, (A & B)[] and A & B[], (keyof T)[] and keyof T[], and keyof (A & B) and keyof A & B stay different. They're in the 'keeps different' cases, which failed before.

/** @param {string} args already normalized */
function arrayOf(args) {
const top = splitTop(args, '|');
return top.length > 1 || args.includes('=>') ? `(${args})[]` : `${args}[]`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

arrayOf should use the same test as the group branch: Array<A & B> normalizes to A&B[], which reads as A & (B[]), and Array<() => void> keeps its parentheses here while (() => void)[] loses them in the group branch, so those two spellings of one type compare different.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1854a0a. arrayOf uses the same primary-type test as the group branch. Array<A & B> reads as (A&B)[], and Array<() => void> now matches (() => void)[]. An object type containing a callback (Array<{f: () => void}>) no longer gains parentheses it doesn't need. Both pairs are in the tests.

members.add(normalizeMember(inner));
};
for (const part of splitTop(text.replace(/^\|/, ''), '|', true)) visit(part);
members.delete('never');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This runs for every nested list too (parameters, tuple members, generic arguments), where never is the whole type rather than a union member: (x: never) => void normalizes like () => void, and [never] and Array<never> like []. Drop never only when the union has other members.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1854a0a. never is dropped only from a union that has other members. (x: never) => void, [never] and Array<never> keep it, and the three are in the 'keeps different' cases. string | never still reads as string.

* @param {string} inner
*/
function listMembers(inner) {
return splitTop(inner.replace(/;/g, ','), ',')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This replace (and the one on line 290, and the React. / readonly replace on line 250) also rewrites string-literal contents: {a: 'x;y'} normalizes like {a: 'x,y'}, and 'readonly x' like 'x'. Skip quoted text when rewriting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1854a0a. The ; rewrite in object and list members, the React. strip, and every readonly strip now apply only outside string literals. {a: 'x;y'} and {a: 'x,y'}, 'readonly x' and 'x', and 'React.Node' and 'Node' stay different; all three are in the tests.

* @returns {string}
*/
function normalizeMember(text) {
let rest = text.replace(/^readonly /, '');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: compact removes the space in readonly ( and readonly [, so this strip and the one on line 250 miss them. readonly ("a" | "b")[], which is how TypeScript prints ReadonlyArray<'a' | 'b'>, stays different from ReadonlyArray<'a' | 'b'>, although the header says readonly is ignored.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1854a0a. readonly is set aside before a group or a tuple as well as before a word. readonly ("a" | "b")[] reads as ReadonlyArray<'a' | 'b'> does, and readonly [string, number] as [string, number]. Both pairs are in the 'reads as one' cases.

@josephfarina
josephfarina force-pushed the josephfarina/doctor-prop-drift-components branch from e09f815 to 4be2ee3 Compare October 18, 2026 17:29
@josephfarina
josephfarina force-pushed the josephfarina/doctor-type-text branch from 0bd0306 to 1854a0a Compare October 18, 2026 17:29

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No blocking issues remain: the conflations raised earlier (grouped callbacks, intersections and keyof before [], never outside unions, rewrites inside string literals) are fixed and covered by the new regression tests, and the module adds no public API or behavior. Three edge cases still read different types as one; none blocks this PR, and each is a small follow-up here or in #7667:

  • The Readonly<…> branch skips the primary check, so Readonly<A & B>[] normalizes like A & B[].
  • A redundant group after keyof loses its space, so Array<keyof (B)> and keyof (Array<B>) both become keyofB[].
  • Parameter names are dropped but a type predicate keeps its name, so (a: X, b: X) => a is T equals (b: X, a: X) => a is T.

[Automated review]

if (name === 'Array' || name === 'ReadonlyArray') {
out = out.slice(0, -name.length) + arrayOf(args);
} else if (name === 'Readonly' && !args.includes(',')) {
out = out.slice(0, -name.length) + args;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: this splice skips the primary check that the group branch and arrayOf use, so Readonly<A & B>[] normalizes to A&B[], the same as A & B[], and {f: Readonly<A | B>[]} equals {f: A | B[]}. #7667 keeps Readonly< text out of its text match, so nothing reaches this today. Wrapping args in parentheses unless primary(args) fixes it, and checking splitTop(args, ',').length === 1 instead of !args.includes(',') would let Readonly<{a: string; b: number}> and Readonly<Record<string, string>> read as their argument, as the header says.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 921a37d. Readonly<…> keeps parentheses around an argument that isn't primary when [] follows, so Readonly<A & B>[] reads as (A&B)[] and {f: Readonly<A | B>[]} stays apart from {f: A | B[]}. It also unwraps whenever there's a single argument, checked with splitTop, so Readonly<{a: string; b: number}> and Readonly<Record<string, string>> read as their argument. All four pairs are in the tests; the two different pairs and the two that read as one failed before.

const members = unionMembers(inner, false);
out +=
members.length === 1 && primary(members[0])
? members[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: when the text before the group ends in a word, dropping the parentheses glues the two words. keyof (B) becomes keyofB, which primary no longer treats as a type operator, so Array<keyof (B)> and keyof (Array<B>) both normalize to keyofB[], though the checker reads (keyof B)[] and keyof B[]. Adding a space when out ends, and the member starts, with a word character would fix it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 921a37d. When the parentheses are dropped and the text before them ends in a word, and the member inside starts with one, a space stays between them. keyof (B) reads as keyof B, so Array<keyof (B)> is (keyof B)[] and keyof (Array<B>) is keyof B[]. The pair is in the 'keeps different' cases.

const [, rest = '', name = '', optional = '', type = ''] = match;
// `this` types the call, and is no parameter.
const self = name === 'this' ? 'this:' : '';
return `${rest}${self}${optional}${normalizeTypeText(type).join('|')}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: the name is dropped here while the return text keeps a type predicate's name, so (a: unknown, b: unknown) => a is string and (b: unknown, a: unknown) => a is string normalize alike, though they narrow different arguments, while (v: unknown) => v is string reads apart from (value: unknown) => value is string. Rare in prop types.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 921a37d. A type predicate's subject is replaced by its parameter's place before the names are set aside: (a: unknown, b: unknown) => a is string reads as (unknown,unknown)=>#0 is string, and the swapped form as #1. (v: unknown) => v is string now reads like (value: unknown) => value is string. asserts x and asserts x is T work the same way, and this is T is kept. Both pairs are in the tests.

@josephfarina
josephfarina force-pushed the josephfarina/doctor-prop-drift-components branch from 4be2ee3 to 37c168d Compare October 18, 2026 19:18
@josephfarina
josephfarina force-pushed the josephfarina/doctor-type-text branch from 1854a0a to 921a37d Compare October 18, 2026 19:18

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested: the earlier follow-ups are fixed, but normalizeTypeText still reads two kinds of different types as one type, and both reach the stacked prop-type check in #7667, which would report a wrong documented type as a match. Please fix these and add each pair to the 'keeps different' tests:

  • A function in a conditional's true branch takes the false branch into its return type, so T extends string ? () => B : C | A and T extends string ? () => A | B : C normalize alike.
  • The predicate-by-place rewrite matches is with a trailing space, which compaction removes before a quote or bracket, so (a: unknown, b: unknown) => a is 'x' and (b: unknown, a: unknown) => a is 'x' still normalize alike.

As a smaller follow-up, Readonly<A | B> & C and keyof Readonly<A & B> still lose their grouping, though #7667 compares Readonly< text exactly today.

[Automated review]

(after.startsWith('[') && !primary(args) ? `(${args})` : args);
} else out += `<${args}>`;
} else if (after.startsWith('=>')) {
const returns = predicateByPlace(inner, after.slice(2));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In a conditional's true branch this takes the false branch into the return type: for T extends string ? () => B : C | A, returns is B:C|A, which is split on | and sorted, so it normalizes to (T extends string?()=>A|B:C), the same as T extends string ? () => A | B : C, though the checker reads () => B and () => A | B. Ending the return type at the conditional's : (or keeping a true-branch function parenthesized) would keep them apart.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 66ab2df, by rebuilding the reading on the parser. normalizeTypeText now parses the text with the project's own TypeScript and prints the tree canonically, so a function's return type is exactly what the parser gives it. Below the top of a type, a function or conditional type always prints in parentheses. T extends string ? () => B : C | A and T extends string ? () => A | B : C print as (T extends string?(()=>B):A|C) and (T extends string?(()=>A|B):C). The pair is in the 'keeps different' cases.

*/
function predicateByPlace(params, returns) {
const match =
/^(asserts )?([\w$]+) is /.exec(returns) ??

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

compact drops the space between is and a following quote or bracket, so a is 'x', a is {…}, a is […] and a is (…) arrive as a is'x' and skip this rewrite: (a: unknown, b: unknown) => a is 'x' and (b: unknown, a: unknown) => a is 'x' still normalize alike, and (v: unknown) => v is 'x' still reads apart from (value: unknown) => value is 'x'. Matching is(?![\w$]) instead of is would cover them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 66ab2df. The predicate's subject comes from the parsed TypePredicate node, not from the text, so spacing can't hide it: (a: unknown, b: unknown) => a is 'x' prints its subject as #0 and the swapped form as #1, and (v: unknown) => v is 'x' now reads like (value: unknown) => value is 'x'. Both pairs are in the tests. Readonly<A | B> & C and keyof Readonly<A & B> keep their grouping too, since Readonly<…> now prints its argument grouped wherever it sits.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested: the parser-based rebuild fixes the conflations raised last time, and the module adds no public API or behavior, but one pair of different types still reads as one. A method named by the string 'new' prints like a construct signature, so {'new'(): X} and {new(): X} both normalize to {new():X} although TypeScript accepts neither for the other, and the stacked prop-type check (#7667) would call such a documented type a match. Keeping a string-literal name quoted when it is new (or printing construct signatures in a form no method name can produce), with the pair in the 'keeps different' tests, would resolve it.

[Automated review]

function propertyName(ts, name) {
if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) return name.text;
if (ts.isStringLiteral(name)) {
return /^[A-Za-z_$][\w$]*$/.test(name.text) ? name.text : quote(name.text);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unquoting an identifier-like name here lets a method named by the string 'new' print as new(...):R, which is exactly how a construct signature prints (line 510). So {'new'(): X} and {new(): X} both normalize to {new():X}, although TypeScript accepts neither for the other. Keeping the name quoted when it is new (or printing construct signatures in a form no method name can produce) would keep them apart; the pair belongs in the 'keeps different' tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in a0067ad. A construct signature now prints as new (…), with a space. No method name prints that way: an identifier-like quoted name prints bare, and any other stays quoted. So {'new'(): X} prints {new():X} and {new(): X} prints {new ():X}. The pair is in the 'keeps different' tests.

@josephfarina
josephfarina force-pushed the josephfarina/doctor-prop-drift-components branch from 45fa44a to e28ccbd Compare October 19, 2026 00:43
@josephfarina
josephfarina force-pushed the josephfarina/doctor-prop-drift-components branch from 2143de3 to 515eab6 Compare October 19, 2026 12:42
@josephfarina
josephfarina force-pushed the josephfarina/doctor-type-text branch from bba990c to 2f822c8 Compare October 19, 2026 12:42

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No blocking issues: normalizeTypeText is an internal helper that nothing calls yet, its 73 tests pass on TypeScript 4.8.4, 5.0.4 and 6.0.3, and checking it against the TypeScript checker found no two different valid types that it reads as one. Two optional follow-ups before #7667 relies on it:

  • It returns the array it caches, so a caller that changes a result in place changes what later calls return for the same text; freezing the cached array or returning a copy would close that.
  • The checker-rejected forms noted earlier (<in T>, index signatures with an optional, rest or extra parameter, a property initializer, Foo<A,>) still read as their valid neighbour instead of coming back unparsed.

[Automated review]

}
const key = `${optional ? 1 : 0}${text}`;
const hit = cache.get(key);
if (hit) return hit;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional, non-blocking: this returns the cached array itself (line 634 stores the same array), so a caller that changes a result in place changes what every later call returns for this text and compiler. For example, pushing 'X' into the result for 'string | number' makes the next call return ["number", "string", "X"]. Freezing result before caching it (with @returns {readonly string[]}) or returning a copy would keep the cache safe; #7667's current code only joins and spreads the results, so nothing is affected today.

@josephfarina
josephfarina force-pushed the josephfarina/doctor-prop-drift-components branch from 515eab6 to 788903f Compare October 19, 2026 15:35
@josephfarina
josephfarina force-pushed the josephfarina/doctor-type-text branch from 2f822c8 to acb2521 Compare October 19, 2026 15:35

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No blocking issues: the unparsed mark now covers in and out type parameters, malformed index signatures, property initializers and trailing commas in type arguments, all 73 type-text tests pass on TypeScript 4.8.4, 5.0.4 and 6.0.3, and checking this revision against the TypeScript checker found no two different valid types that normalizeTypeText reads as one. One optional follow-up remains before #7667 relies on the module: normalizeTypeText returns the array it caches, so a caller that changes a result in place changes what later calls return for the same text; freezing the cached array or returning a copy would close that.

[Automated review]

@josephfarina
josephfarina force-pushed the josephfarina/doctor-prop-drift-components branch from 788903f to e0bf813 Compare October 19, 2026 16:40
@josephfarina
josephfarina force-pushed the josephfarina/doctor-type-text branch from acb2521 to 988b3ac Compare October 19, 2026 16:40

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No blocking issues: freezing the cached result closes the last open follow-up, since a caller's change now throws and leaves later results intact, and all 74 type-text tests pass on TypeScript 4.8.4, 5.0.4 and 6.0.3. Checking this revision against the TypeScript checker found no two different valid types that normalizeTypeText reads as one. Nothing else is needed here; it can merge after #7676 in the order the description gives.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No blocking issues: judgeType and normalizeTypeText add no public API or output change, all 75 of their tests pass, and checking judgeType against the TypeScript checker on 4.8.4, 5.0.4 and 6.0.3 found its verdicts right for realistic types. Two optional follow-ups in type-match.mjs, before #7667 relies on it:

  • String-literal parts are built unescaped and joined with |, so a literal containing '|' reads as two literals and "a'|'b" is judged a match for 'a' | 'b'; escaping the literal text on both sides, as type-text.mjs does, would close it.
  • A string enum member beside its own value counts as an extra part, so 'a' | 'b' and E | 'a' | 'b' (E a string enum with those values) are reported as a mismatch although TypeScript takes each for the other.

[Automated review]

ts.isStringLiteral(literal) ||
ts.isNoSubstitutionTemplateLiteral(literal)
)
return [`'${literal.text}'`];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: string-literal parts are built unescaped here and at line 382, and reduced joins parts with |, so a literal whose text contains '|' reads as two literals: a doc of "a'|'b" is judged a match for code typed 'a' | 'b', and the reverse, though TypeScript assigns neither to the other. Escaping \ and ' in the text on both sides, as quote in type-text.mjs does, keeps every literal's boundaries unambiguous.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 64f7fea: each string-literal part is now quoted with \ and ' escaped on both sides, and an enum member's value is encoded, so "a'|'b" and 'a' | 'b' are a mismatch both ways. The verdict test pins it, and it failed before.

);
if (kept.has('string')) {
for (const atom of [...kept]) {
if (atom.startsWith("'") || atom.startsWith('enum:')) kept.delete(atom);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: without string in the union, a string enum member beside its own value stays a separate part, so 'a' | 'b' and E | 'a' | 'b' (E a string enum with those values) are judged a mismatch either way round, although TypeScript assigns each to the other. Dropping an enum: part whose value is also present as a plain literal, or saying in #7667's guide that such a pair is reported, would settle it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 64f7fea: an enum: part whose value is also present as a plain literal is dropped, as TypeScript passes the member where that literal is taken. 'a' | 'b' and E | 'a' | 'b' now match both ways, while E | 'a' with E also holding 'b' still differs from 'a' | 'b', as tsc rejects 'b' there. The verdict test pins both directions.

// matches only as written: the same text, whitespace aside, with
// `readonly` kept on both sides. Any other goes to the parts below.
const readonly = (/** @type {string} */ text) =>
/\breadonly\b(?!\s*[:?])|\bReadonly(?:Array)?\s*</.test(text);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: this also matches readonly inside a string literal, so a callback typed (mode: 'readonly' | 'edit') => void and documented as (mode: 'edit' | 'readonly') => void takes the exact-text path and comes back not_compared, where the same type with another word is a match. Testing the text with its string literals masked, which QUOTED_OR_SPACE already finds, would keep such types in the normalized comparison.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 64f7fea: the readonly test now reads the text with its string literals masked, and so does the comparison that sets readonly aside, so (mode: 'readonly' | 'edit') => void documented in the other order is a match. The verdict test pins it, and it failed before.

@josephfarina
josephfarina force-pushed the josephfarina/doctor-prop-drift-components branch from ec0c4fa to bc10598 Compare October 19, 2026 19:45
@josephfarina
josephfarina force-pushed the josephfarina/doctor-type-text branch from 72f3391 to 64f7fea Compare October 19, 2026 19:46

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No blocking issues: normalizeTypeText and judgeType add no public API or output change, all 75 of their tests pass, and checking both against the TypeScript checker on 4.8.4, 5.0.4 and 6.0.3 found no false match, with the three earlier type-match.mjs follow-ups fixed. Two optional follow-ups before #7667 relies on the judge:

  • encodeURIComponent(t.value) (type-match.mjs line 404) throws URIError for a string enum value holding an unpaired surrogate such as '\uD800', so judgeType throws instead of returning a verdict; an encoding that cannot throw, or treating such a member as not compared, would close it.
  • A union member another member already covers (string[] beside (string | number)[], an array beside object, undefined beside void) is reported as a mismatch when only one side writes it, though TypeScript treats both as one type; folding such parts, or saying so in the guide, would settle it.

[Automated review]

const owner = decl && checker.getSymbolAtLocation(decl.parent.name);
if (!(t.flags & F.StringLiteral) || !owner) return null;
out.push(
`enum:${owner.name}#${encodeURIComponent(checker.getFullyQualifiedName(owner))}#${encodeURIComponent(t.value)}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: encodeURIComponent throws URIError for a string enum value holding an unpaired surrogate, so with enum Lone { A = '\uD800', B = 'b' } judging a prop typed Lone against the doc 'b' throws instead of returning a verdict. An encoding that cannot throw, or returning null (not compared) when this one does, would keep such a type listed as unchecked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 60fa1df: enum parts now encode the value (and the enum's full name) by code point, which never throws, so Lone with '\uD800' gets a verdict. The verdict test pins it; it threw before.

}
}
}
return [...kept].sort().join('|');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: a part that another part already covers stays a part of its own, so string[] | (string | number)[] against (string | number)[], object | string[] against object and void | undefined against void each come back mismatch, though TypeScript assigns each pair both ways. Dropping a part another part subsumes here, or saying in #7667's guide that such a pair is reported, would settle it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Folded in 60fa1df, as tsc reads it: undefined beside void, an array beside object, and an array whose element types another array also takes add nothing. A readonly array covers a mutable one but not the reverse, so readonly string[] | (string | number)[] against (string | number)[] stays a mismatch (tsc rejects it). The verdict test pins each case; they were mismatches before. #7667's guide states the rule.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No blocking issues: normalizeTypeText and judgeType change no CLI output until #7667 wires them, all 75 of their tests pass, and checks against the TypeScript checker on 4.8.4, 5.0.4 and 6.0.3 found no false match in a strict project, with the new folding of covered members and the React.X resolution giving the checker's answer outside the two cases below. Two optional follow-ups before #7667 relies on the judge:

  • arrayCovers doesn't count a string enum member as covered by its own value's literal, so E[] | ('a' | 'b')[] against ('a' | 'b')[] is still a mismatch although tsc assigns each to the other.
  • Without strict null checks, null and undefined are dropped even beside never, so a doc of undefined for a prop typed never is a match although tsc rejects that value.

[Automated review]

Comment on lines +338 to +348
return inner(narrow).every(
part =>
taken.includes(part) ||
(taken.includes('string') &&
(part.startsWith("'") || part.startsWith('enum:'))) ||
(taken.includes('number') && /^(?:-?\d|NaN$|-?Infinity$)/.test(part)) ||
(part === 'undefined' && taken.includes('void')) ||
(isArrayPart(part) &&
(taken.includes('object') ||
taken.some(other => arrayCovers(other, part)))),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: reduced drops a string enum member beside the literal of its own value (lines 262–267), but this check doesn't apply that rule to element types. So code typed ('a' | 'b')[] against the doc E[] | ('a' | 'b')[], or 'a'[] against E.A[] | 'a'[] (with E a string enum of 'a' and 'b'), is still a mismatch, though tsc assigns each to the other. Also counting an enum: part as covered when taken holds literalPart(decodePart(value)) would close it.

Comment on lines +253 to +254
!(atom === 'undefined' && ((top && ctx.optional) || ctx.nullable)) &&
!(atom === 'null' && ctx.nullable),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: with nullable, null and undefined are dropped even where the other side is never, so a doc of undefined or null for a prop typed never (or readonly never[] | undefined[] for readonly never[]) is a match, while tsc rejects undefined for never even without strict null checks. Keeping them, or returning not_compared, wherever dropping them would leave a union or an array element empty would close it.

@josephfarina
josephfarina force-pushed the josephfarina/doctor-prop-drift-components branch from bc10598 to a6638ab Compare October 19, 2026 22:37
…only in how they are written

`normalizeTypeText(text, {ts, optional})` parses type text with the
project's own TypeScript and prints the type canonically, so grouping
always comes from the parse. It merges only what TypeScript reads as one
type: comments, whitespace, needless parentheses and quote style;
function and tuple parameter names (a `this` parameter is kept, and a type
predicate names its parameter by place); the order of union members and
object properties; `Array<T>` and `T[]`; a `React.` qualifier; `true |
false` and `boolean`; a literal beside its primitive; `never` among other
union members; and `undefined` on an optional prop. It sets `readonly`,
`Readonly<…>` and `ReadonlyArray<…>` aside. Below the top of a type, a
function or conditional type always prints in parentheses. Text that does
not parse comes back marked, in a form no printed type takes, so it never
matches; so does `Foo<>`, `Foo<A,>`, `typeof Foo<>`, a JSDoc form
(`Array.<T>`, `?string`, `*`), a mapped type with members, a parameter with
a modifier or an initializer, a member with a modifier other than
`readonly`, a property with an initializer, an `in` or `out` type
parameter, or an index signature without one plain, typed parameter and a
type, which parse but which only the checker rejects (a comment before the
`<` is no `.`). An import type with attributes, and any form the printer does
not know, is kept as written, with a comment between two words read as
the space it stands for (`get/*c*/a` is `get a`, not `geta`), and an
accessor without the `;` or `,` that ends it. `typeTextTooOld` names a
project TypeScript older than 4.8, the oldest every printed form is
tested on, so a caller can skip the comparison instead of failing.
Moved from the prop types PR, which uses it.

The result is cached per TypeScript and frozen, so a caller that changes
it cannot change the next caller's.
…lares

`judgeType(docType, codeTypes, ctx)` judges a documented type only where
both sides read exactly: the same text once type-text.mjs normalizes both,
or the same exact parts (primitives, literals, string enum members of
their own enum, arrays of these, unions of these, and names that resolve
to them where the prop is declared), in any order, as TypeScript reduces a
union. A mutable array type matches a readonly one, since callers can pass
it; a readonly one documented for a mutable prop is a mismatch. A string
literal is one part, quoted and escaped, so no text inside it reads as
other parts (`"a'|'b"` is not `'a' | 'b'`, and `'readonly'` is a word,
not the modifier), and a string enum member beside the literal of its
own value adds nothing, as TypeScript passes the member where that
literal is taken. `React.X` resolves through `React` where the prop is
declared, as the code reads it; where a bare `X` there names another
type (the DOM's `KeyboardEvent`, a file's own `Key`), the two are never
read as one, though the text reading sets the qualifier aside. A union
member another member covers adds nothing, as TypeScript passes it
there: `undefined` beside `void`, an array beside `object`, and an array
whose element types another array also takes (a readonly array covers a
mutable one, not the reverse). An enum part encodes its value by code
point, so no string, an unpaired surrogate included, fails to encode. Every
other form is not compared, never reported as a mismatch. A doc may spell
an alias out as `Name = expansion`; one value after it is a default, and
only the name is judged.

Moved here from the prop types PR, which wires it into the comparison, so
that PR stays under 2,000 changed lines. Nothing reaches it yet.
@josephfarina
josephfarina force-pushed the josephfarina/doctor-type-text branch from 60fa1df to ed28364 Compare October 19, 2026 22:57
@josephfarina
josephfarina changed the base branch from josephfarina/doctor-prop-drift-components to main October 19, 2026 22:57
@josephfarina
josephfarina merged commit dd769fe into main Oct 19, 2026
47 checks passed
@github-actions
github-actions Bot deleted the josephfarina/doctor-type-text branch October 20, 2026 06:22

This branch was successfully deployed

1 active deployment
Preview — ed283642 Deployed Oct 19, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant