Skip to content

Reverse-mapped inference exposes private members as public; since #63932 this rejects f<T>() as C #64593

Description

🔎 Search Terms

reverse mapped type private member, resolveReverseMappedTypeMembers, private is private in type but not in type, TS2352 private intersection readonly, inference private property public, keyof private member, syntheticProtectedProperties, 63932

🕗 Version & Regression Information

⏯ Playground Link

No response

💻 Code

// @strict: true
class Entity {
    private secret = 1;
    markChanged(field: string): void {}
}
class Account extends Entity {
    emailAddress = '';
}
declare function getAccount<T extends object = object>(): Readonly<Account & T>;

export const ok = getAccount<object>() as Account;  // fine everywhere
export const b = getAccount() as Account;           // TS2352 since 8239985a01

🙁 Actual behavior

On main (09b1db0) and since 8239985:

error TS2352: Conversion of type 'Readonly<Account & { secret: number; markChanged: (field: string) => void; emailAddress: string; }>' to type 'Account' may be a mistake because neither type sufficiently overlaps with the other.
  Property 'secret' is private in type 'Account' but not in type 'Readonly<Account & { secret: number; ... }>'.

The inferred T contains secret as a public property, which no keyof-based mapping could produce.

🙂 Expected behavior

No error, as in TypeScript 6.0.3 and earlier native previews. T should be inferred without the private (or protected) members of Account, because a reverse mapping recovers the source of a mapped type keyed by keyof, and keyof never yields a private or protected member.

Additional information about the issue

Cause. The assertion contextually types the call, so T is inferred by reverse-mapping Account through Readonly<Account & T>. resolveReverseMappedTypeMembers copies every source property, secret included. The copy has the member's declarations but no accessibility, so it reads as public.

That inferred T isn't new; what changed is what it does. Before #63932, the intersection property secret in Account & T resolved as private, keyof dropped it, and Account stayed comparable. After #63932, intersection properties take the most permissive accessibility (as syntheticProtectedProperties.ts asserts, deliberately), so the public copy wins and the comparison fails both ways. So #63932 isn't the bug; it exposed one.

Both conditions are required, which is why Readonly<Class & object> as Class alone doesn't reproduce it:

variant result
explicit getAccount<object>() (no inference) no error
Entity without a private member no error
inference + private member TS2352

Suggested fix. Skip non-public source properties in resolveReverseMappedTypeMembers. That leaves #63932's accessibility rule untouched. I have a PR ready with a regression test and baselines, and the full pre-PR checklist passing.

Real-world impact. Hit in a real codebase on two casts of the shape context.getAccount() as Account, where an entity base class carries private members. The code is correct and type-checks under tsc 6.0.3.

AI disclosure. I found and diagnosed this with the help of Claude Code (Anthropic). I've read and understand the analysis, and I'll be the one following up here.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions