Skip to content

Regression: recursive conditional+mapped "serialize" type no longer proves Transform<X> assignable to X on deeply-nested compound types (native-preview; works in tsc) #63845

Open

Description

@dimofeev

Environment

  • @typescript/native-preview (tsgo)
  • Last good: 7.0.0-dev.20260421.2
  • Broken: 7.0.0-dev.20260618.1 … 7.0.0-dev.20260622.1 (latest) — all fail
  • Stable typescript@5.9.3: OK (accepts the code)

So: same source, only the native-preview version changed → a tsgo regression somewhere between 20260421.2 and 20260618.1.

What breaks

A common recursive "serialize" utility type — maps Date | bigint → string recursively through objects and arrays, leaving everything else unchanged (so for a type X containing no Date/bigint, Transform<X> is structurally identical to X):

type Transform<Base, From, To> = {
  [K in keyof Base]: Exclude<Base[K], undefined | null> extends never ? Base[K]
    : Exclude<Base[K], undefined | null> extends object ? (Exclude<Base[K], undefined|null> extends From ? To | Extract<Base[K], null|undefined> : Transform<Base[K], From, To>)
    : Base[K];
};
type TransformJson<T> = Transform<T, Date | bigint, string>;

For a deeply-nested compound type — recursion + arrays + an 8-member discriminated union with a shared base ({base} & ({type:1;…} | {type:2;…} | …)) nested several levels behind arrays — tsgo fails to prove TransformJson<X> assignable to X even when X has no Date/bigint (i.e. when the transform is provably the identity):

error TS2322: Type 'Transform<…, bigint | Date, string>' is not assignable to type '…'.
  Type 'Transform<{base} & (…union…), bigint | Date, string>' is not assignable to '{base} & (…union…)'.

tsc proves this assignable; tsgo (new builds) does not.

Diagnosis / things tried (in case it helps narrow the regressed area)

  • Adding an up-front "does this subtree contain From?" guard so the transform returns Base verbatim for no-From subtrees → flips the failure into TS2589 "Type instantiation is excessively deep and possibly infinite" across the project, i.e. the extra recursive pass exhausts a depth/instantiation budget.
  • Bounding the transform's recursion depth (no extra pass) → tsgo then fully expands the type (no TS2589) but still rejects the expanded {base} & union intersection-with-union assignment.
  • So it looks like two compounding limits: (a) a recursion/instantiation budget that's tighter or counted differently than in tsc, and (b) assignability of a mapped-type result back to an intersection-with-union, at depth.

Reproduction note (the awkward part)

I could not reduce this to a small standalone repro: the minimal/standalone versions of the exact pattern (even with the same 8-member intersection-with-union behind arrays) compile clean under both tsgo and tsc. It only reproduces inside a large real project (many hundreds of interacting Transform<…> instantiations across the program), which points at a scale-dependent / cumulative instantiation budget rather than a single-type defect.

Happy to help bisect against the dev-build range, or to share a private reproduction with the team if that's an option.

Activity

  1. added
    Needs More InfoThe issue still hasn't been fully clarified
    and removed
    Needs InvestigationThis issue needs a team member to investigate its status.
    on Jun 24, 2026
  2. RyanCavanaugh commented on Jun 24, 2026

    @RyanCavanaugh
    Member

    We really need a concrete repro in order to investigate these sorts of things. You can try using the reduce-repro skill in an AI agent if that's available to you.

  3. dimofeev commented on Jul 2, 2026

    @dimofeev
    Author

    Here is a minimal, self-contained reproduction (~30 lines of code, public npm packages only).

    Setup

    mkdir tsgo-4408-repro && cd tsgo-4408-repro && mkdir src
    npm init -y
    npm install --save-dev @types/hast@^3.0.4 hast-util-to-jsx-runtime@^2.3.6 typescript@^5.9.3

    tsconfig.json:

    {
      "compilerOptions": {
        "strict": true,
        "noEmit": true,
        "module": "commonjs",
        "target": "es2022",
        "lib": ["es2022"],
        "skipLibCheck": true
      },
      "include": ["src/**/*.ts"]
    }

    src/transform.ts — the same generic recursive "serialize" mapped type as in the issue description (maps Date | bigint → string deeply; identity for shapes containing neither):

    export type Transform<Base, From, To> = {
        [K in keyof Base]: Exclude<Base[K], undefined | null> extends never ? Base[K]
            : Exclude<Base[K], undefined | null> extends object ? (Exclude<Base[K], undefined | null> extends From ? To | Extract<Base[K], null | undefined> : Transform<Base[K], From, To>)
            : Base[K];
    };
    export type TransformJson<T> = Transform<T, Date | bigint, string>;

    src/repro.ts:

    import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
    import type { Root } from 'hast';
    import type { TransformJson } from './transform';
    
    // keep the import live so it is not elided
    export const _keep = toJsxRuntime;
    
    // A JSON client: responses have Dates serialized to strings, i.e. TransformJson<T>.
    declare function fetchJson<T>(): TransformJson<T>;
    declare function parseDate(s: string): Date;
    
    interface IMessage {
        createdAt: Date;
        content: Root;
    }
    
    interface IAggregate {
        createdAt: Date;
        messages: IMessage[];
    }
    
    export function getAggregate(): IAggregate {
        // NOTE the double wrap: fetchJson<T> returns TransformJson<T>, and T here is
        // itself TransformJson<IAggregate> -> TransformJson<TransformJson<IAggregate>>.
        const response = fetchJson<TransformJson<IAggregate>>();
    
        return {
            ...response,
            createdAt: parseDate(response.createdAt),
            messages: response.messages.map(message => ({
                ...message,
                createdAt: parseDate(message.createdAt),
                // `content` is intentionally left untouched, so it stays
                // Transform<Transform<Root>> and must be checked against Root.
            })),
        };
    }

    Reproduce

    # stable tsc — clean
    npx tsc -p tsconfig.json
    
    # last good native-preview build — clean
    npm install --no-save @typescript/native-preview@7.0.0-dev.20260421.2
    npx tsgo -p tsconfig.json
    
    # regressed — TS2322
    npm install --no-save @typescript/native-preview@7.0.0-dev.20260618.1
    npx tsgo -p tsconfig.json
    
    # latest at time of writing — still TS2322
    npm install --no-save @typescript/native-preview@7.0.0-dev.20260701.1
    npx tsgo -p tsconfig.json

    Results

    Compiler Result
    tsc 5.9.3 ✅ no errors
    @typescript/native-preview 7.0.0-dev.20260421.2 ✅ no errors
    @typescript/native-preview 7.0.0-dev.20260618.1 ❌ TS2322
    @typescript/native-preview 7.0.0-dev.20260701.1 ❌ TS2322

    Error on the regressed builds:

    src/repro.ts:58:9 - error TS2322: Type '{ content: Transform<Transform<Root, bigint | Date, string>, bigint | Date, string>; createdAt: Date; }[]' is not assignable to type 'IMessage[]'.
        The types of 'content.children' are incompatible between these types.
          Type 'Transform<Transform<RootContent, ...>, ...>[]' is not assignable to type 'RootContent[]'.
            ...
              Type 'Transform<Transform<Element, ...>, ...>' is not assignable to type 'Element'.
                Types of property 'children' are incompatible.
                  Type 'Transform<Transform<ElementContent, ...>, ...>[]' is not assignable to type 'ElementContent[]'.
                    ...
    

    Transform<Transform<Root>> is structurally identical to Root (the inner Transform maps Date | bigint → string; the outer one finds nothing left to map), so the assignment is valid — as tsc and the 20260421.2 build both confirm.

    Notes on the ingredients

    All three of these are required — removing any one makes the code compile clean on every version (which is why this was hard to reduce out of a large real project):

    1. The double TransformJson<TransformJson<T>>. A single TransformJson<T> is clean.
    2. A recursively-typed field left un-narrowed — content: Root is spread through unchanged, so the checker must verify Transform<Transform<Root>> ⊆ Root, recursing through hast's mutually-recursive Root → RootContent → Element → ElementContent → Element.
    3. Other recursive AST declarations loaded in the same program. The bare import { toJsxRuntime } from 'hast-util-to-jsx-runtime' (transitively pulling @types/estree, mdast-util-mdx-jsx, hast-util-whitespace, …) is enough; deleting that single import makes the error disappear, even though nothing from it is used in the failing code. In the original project, checking the same file in isolation is clean — the error only appears once a sibling file imports a package with a similar closure. So the failure appears to depend on checker-wide state accumulated from unrelated type declarations, not just on the types in the comparison itself.
  4. jakebailey commented on Jul 2, 2026

    @jakebailey
    Member
  5. jakebailey commented on Jul 2, 2026

    @jakebailey
    Member

    Possibly the same explanation as microsoft/typescript-go#4451 though I'm no expert

  6. jakebailey commented on Jul 2, 2026

    @jakebailey
    Member

    Not so minimal repro

    // @strict: true
    // @noEmit: true
    // @target: es2022
    // @module: commonjs
    // @skipLibCheck: true
    
    // @filename: /types.d.ts
    declare module "estree" {
        export interface Program extends BaseNode { body: Array<Directive | Statement | ModuleDeclaration>; }
        export interface Directive extends BaseNode { body: BlockStatement | Expression; }
        export type Statement = Declaration;
        export type Declaration = FunctionDeclaration | VariableDeclaration | ClassDeclaration;
        export interface FunctionDeclaration extends MaybeNamedFunctionDeclaration { id: Identifier; }
        export interface VariableDeclaration extends BaseDeclaration { consequent: Statement[]; }
        export type Literal = SimpleLiteral | RegExpLiteral | BigIntLiteral;
        export interface SimpleLiteral extends BaseNode, BaseExpression { value: string | boolean | number | null; }
        export interface RegExpLiteral extends BaseNode, BaseExpression { value?: RegExp | null | undefined; regex: {}; }
        export interface BigIntLiteral extends BaseNode, BaseExpression { raw?: string | undefined; }
        export interface ClassDeclaration extends MaybeNamedClassDeclaration { property: Identifier; }
        export type ModuleDeclaration = ExportAllDeclaration;
        export interface ExportAllDeclaration extends BaseModuleDeclaration { source: Literal; }
    }
    
    declare module "estree-jsx" {
        export * from "estree";
    }
    
    declare module "hast" {
        export type ElementContent = ElementContentMap[keyof ElementContentMap];
        export type RootContent = RootContentMap[keyof RootContentMap];
        export interface RootContentMap { comment: Comment; doctype: Doctype; element: Element; text: Text; }
        export interface Comment extends Literal { type: "comment"; data?: CommentData | undefined; }
        export interface Doctype extends UnistNode { type: "doctype"; data?: DoctypeData | undefined; }
        export interface Element extends Parent { type: "element"; tagName: string; properties: Properties; children: ElementContent[]; content?: Root | undefined; data?: ElementData | undefined; }
        export interface Root extends Parent { type: "root"; children: RootContent[]; data?: RootData | undefined; }
        export interface Text extends Literal { type: "text"; data?: TextData | undefined; }
    }
    
    declare module "mdast-util-mdx-jsx" {
        import type { Program } from "estree-jsx";
        export interface MdxJsxExpressionAttribute extends Node { estree?: Program | null | undefined; }
        export interface MdxJsxAttribute extends Node { data?: MdxJsxAttributeData | undefined; }
        export interface MdxJsxTextElementHast extends HastParent { attributes: Array<MdxJsxAttribute | MdxJsxExpressionAttribute>; }
    }
    
    declare module "hast" {
        import type { MdxJsxTextElementHast } from "mdast-util-mdx-jsx";
        interface ElementContentMap { mdxJsxTextElement: MdxJsxTextElementHast; }
    }
    
    // @filename: /repro.ts
    import "mdast-util-mdx-jsx";
    import type { Root } from "hast";
    
    type Transform<Base, From, To> = {
        [K in keyof Base]: Exclude<Base[K], undefined | null> extends never ? Base[K]
            : Exclude<Base[K], undefined | null> extends object ? Exclude<Base[K], undefined | null> extends From ? To | Extract<Base[K], null | undefined> : Transform<Base[K], From, To>
            : Base[K];
    };
    type TransformJson<T> = Transform<T, Date | bigint, string>;
    
    declare function fetchJson<T>(): TransformJson<T>;
    declare function parseDate(s: string): Date;
    
    interface IMessage { createdAt: Date; content: Root; }
    interface IAggregate { createdAt: Date; messages: IMessage[]; }
    
    export function getAggregate(): IAggregate {
        const response = fetchJson<TransformJson<IAggregate>>();
        return { ...response, createdAt: parseDate(response.createdAt), messages: response.messages.map(message => ({ ...message, createdAt: parseDate(message.createdAt) })) };
    }
  7. jakebailey commented on Jul 2, 2026

    @jakebailey
    Member

    More minimal:

    // @strict: true
    // @noEmit: true
    
    type Transform<T> = {
        [K in keyof T]: T[K] extends object ? Transform<T[K]> : T[K];
    };
    
    type Child = ChildMap[keyof ChildMap];
    
    interface ChildMap { root: Root; }
    interface Root { child: Child; }
    
    interface ChildMap { element: XMLElement; }
    interface XMLElement { parent: Root; fn(): void; }
    
    declare const root: Transform<Transform<Root>>;
    const result: Root = root;
  8. jakebailey commented on Jul 2, 2026

    @jakebailey
    Member
  9. jakebailey commented on Jul 2, 2026

    @jakebailey
    Member

    Thinking harder, I'm 99% percent certain it's exactly the same and this is working as intended.

  10. ahejlsberg commented on Jul 2, 2026

    @ahejlsberg
    Member

    Agree, this seems exactly like microsoft/typescript-go#4451. And thus working as intended.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs More InfoThe issue still hasn't been fully clarified

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions