Skip to content

Writable: Fix support for polymorphic this and generics - #1516

Open
deshrajvermay9517-png wants to merge 3 commits into
sindresorhus:mainfrom
deshrajvermay9517-png:fix-writable-this
Open

deshrajvermay9517-png wants to merge 3 commits into
sindresorhus:mainfrom
deshrajvermay9517-png:fix-writable-this

Conversation

@deshrajvermay9517-png

@deshrajvermay9517-png deshrajvermay9517-png commented Sep 6, 2026

Copy link
Copy Markdown

Fixes #1515.

What was wrong

In #1470 (v5.9.0), Writable was updated to preserve index signatures by using an as clause to filter keys:

& {-readonly [KeyType in keyof BaseType as KeyType extends Keys ? KeyType : never]: BaseType[KeyType]}

When BaseType is an uninstantiated type variable (such as polymorphic this inside a class method or typeof this, or generic T), TypeScript cannot eagerly evaluate the key-filtering conditional in the as clause. As a result, properties on Writable<this> and uninstantiated Writable<T> were dropped, causing errors such as:

class SomeClass {
	readonly field!: number;

	method() {
		(this as Writable<this>).field = 4; // Error: Property 'field' does not exist on type 'Writable<this>'.
	}
}

What changed

  • The omitted Keys argument uses undefined as the sentinel (Keys extends keyof BaseType | undefined = undefined), checked with IsEqual<Keys, undefined> extends true.
  • An exact check via IsEqual<Keys, undefined> ensures unions containing undefined (such as Writable<Model, 'selected' | undefined>) only make the selected keys writable rather than treating the argument as omitted.
  • When Keys is omitted (resolving to undefined), Writable evaluates directly to {-readonly [KeyType in keyof BaseType]: BaseType[KeyType]}. This direct homomorphic mapping:
  • Explicit never or computed key selections that resolve to never remain valid empty key selections and preserve readonly properties and index signatures.
  • Extract<Keys, keyof BaseType> is used when forwarding selected keys to Except.
  • When a subset of Keys is specified (e.g. Writable<T, 'a'> or Writable<Foo, keyof Foo>), it continues through the Except + filtered mapped type path as expected.

How it was tested

  • Added regression tests in test-d/writable.ts covering:
    • (this as Writable<this>).field = 4 and (this as Writable<typeof this>).field = 4 inside class methods.
    • Generic uninstantiated types using default Writable<T> (testGeneric<T>(item: Writable<T>)).
    • Unions with undefined like Writable<Foo, 'a' | undefined>, verifying non-selected properties remain readonly.
    • Explicit Writable<T, never> with negative assignment tests.
    • Computed empty key selections resolving to never (Writable<RecordData, EditableKeys>) with negative assignment tests.
    • Readonly index signatures preserved when explicit never or computed empty selection (Extract<keyof IndexRecord, symbol>) is passed.
    • Explicit concrete keyof BaseType as Keys argument (Writable<Foo, keyof Foo>).
    • Edge cases (any, never, unknown).
  • Focused writable tests in test-d/writable.ts pass cleanly with tsd.
  • Verified repository-wide typecheck (tsc), tests (node --test), and linter (xo on changed files).

@deshrajvermay9517-png deshrajvermay9517-png changed the title Writable: Fix support for polymorphic his and generics Writable: Fix support for polymorphic this and generics Sep 6, 2026

@nrps9909 nrps9909 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.

September 7 update: This review describes the old head. My candidate sentinel check was too broad for key unions with undefined; the maintainer identified that missing case. The latest validated follow-up confirms the author fixed it using exact sentinel equality. The old candidate patch is superseded and should not be used.

The default-argument fix works, but please preserve an explicitly empty key selection before merging. I reproduced the inline regression against 5715dfe23dc5853a148afa37df3c0a698ca9cbb9 and this exact head, 08b29d21cf4391350dd73f0c94bc1f0b4927c582.

On each of TypeScript 5.9.3, 6.0.3 and 7.0.2, 175 independent checks cover every subset of four string/numeric/symbol keys, optional and mixed-readonly properties, index signatures, derived empty selections, collections and generic class/function use. Head repairs three of the six baseline failures, but introduces 12 readonly-assignment regressions when Keys resolves to never. The other three failures are unchanged explicit keyof this / keyof Item generic cases; the new concrete Writable<Foo, keyof Foo> test does not establish that generic variant.

I also tested a candidate that reserves undefined for the omitted argument (Keys extends keyof BaseType | undefined = undefined), branches on undefined extends Keys, and uses Extract<Keys, keyof BaseType> for Except. That preserves the three working fixes and removes all 12 new regressions across those compiler versions. Seven added negative assertions fail on this PR head and pass with the candidate. This is a possible implementation, with the explicit-keyof generic limitation still present; it is not a claim to have resolved that separate limitation.

Repository-wide test:tsc and test:tsd pass on both head and the candidate. The Node test runner passes 38 tests, and focused lint on the candidate's source/test files passes. Full npm test remains red on four XO errors in unchanged abstract-class.ts / readonly-deep.ts; all four reproduce on the exact base with the same installed dependencies and match the upstream failed job. I did not change those files.

Candidate source/test patch and the per-compiler results: apply-ready evidence.

AI-assisted review with independently executed compiler checks.

Comment thread source/writable.d.ts Outdated
// Make the specified keys writable.
& {-readonly [KeyType in keyof BaseType as KeyType extends Keys ? KeyType : never]: BaseType[KeyType]}
>;
: IsNever<Keys> extends true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Preserve readonly properties when the selected key set is empty

never is already a valid explicit key set meaning “make no properties writable”. Using it as the omitted-argument sentinel changes that to “make every property writable”. This also affects ordinary computed selections, not just callers spelling never directly:

type RecordData = {readonly id: string};
type EditableKeys = Extract<keyof RecordData, `editable${string}`>;
declare const data: Writable<RecordData, EditableKeys>;
data.id = 'changed'; // TS2540 on base; accepted on this head

The same regression removes readonly index-signature restrictions. Please distinguish the omitted argument from an empty selection and add negative assignment tests for both explicit never and a computed empty key set. Non-empty subsets and the new default Writable<this> behavior should remain covered.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, thank you! I've updated the implementation to reserve undefined for the omitted argument (Keys extends keyof BaseType | undefined = undefined), and used Extract<Keys, keyof BaseType> when passing keys down.

Explicit never and computed key selections resolving to never now preserve readonly properties and index signatures. Added regression tests covering both explicit and computed never cases to test-d/writable.ts.

@sindresorhus

Copy link
Copy Markdown
Owner

The overall direction makes sense. Using a sentinel for the omitted second argument lets that path use a direct homomorphic mapped type, which fixes Writable<this> and Writable<T> without bringing back the index-signature regression.

I think the implementation should be smaller, thouh. Check the sentinel with IsEqual<Keys, undefined>, then go directly to the existing Simplify branch. [undefined] extends [Keys] is too broad: Writable<Model, 'selected' | undefined> currently makes every property writable, including properties that were not selected.

The later IsEqual<Extract<Keys, keyof BaseType>, keyof BaseType> branch does not fix Writable<T, keyof T> for an uninstantiated T; that still loses all properties. The concrete keyof test already passed before this PR, so the extra branch is not buying anything. I would remove the branch and its claim, and treat generic explicit keyof as a seperate issue if we want to support it. A small generic test using the default form would better match what this PR actually fixes.

One test also iss not exercising an empty computed selection. A string index signature has keyof equal to string | number, so extracting number produces number, not never. Extracting symbol would test the intended case

@deshrajvermay9517-png

Copy link
Copy Markdown
Author

Updated as suggested:

  • Replaced [undefined] extends [Keys] with IsEqual<Keys, undefined> extends true before going directly to the Simplify branch. Added a test for Writable<Foo, 'a' | undefined> verifying non-selected properties remain readonly.
  • Removed the IsEqual<Extract<Keys, keyof BaseType>, keyof BaseType> branch and related claims. Added a test for generic types using the default form Writable<T>.
  • Updated the computed empty index signature test to extract symbol instead of number, properly evaluating to never.

All tests pass cleanly (tsd, tsc, node --test, and xo).

@nrps9909 nrps9909 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.

Rechecked a7ec8550c5989649bb1b3fd8d328663e18bb330d. My reported empty-selection regression is fixed: explicit never and computed empty selections preserve readonly properties/index signatures, while default Writable<this> / Writable<Item> still work.

Thanks for catching the broader sentinel test. My earlier candidate also used undefined extends Keys, so it had the same omission. I expanded the independent matrix to include every subset of four string/numeric/symbol keys unioned with undefined, using both all-readonly and mixed-readonly objects. On each of TypeScript 5.9.3, 6.0.3 and 7.0.2, the previous candidate has 42 additional readonly-assignment failures; the current exact-sentinel implementation fixes all 42. The full expanded set gives 300/303 expected results. The only three failures are the inherited explicit-keyof generic forms, now correctly excluded from this PR's claims. I am marking my old candidate artifact as superseded, with this missing coverage disclosed.

Repository-wide test:tsc and test:tsd, the 38 Node tests, and focused source/test lint pass. Full npm test still reports the same four XO errors in unchanged abstract-class.ts / readonly-deep.ts, matching the exact-base run and the current CI log; I am not claiming full CI is green.

No remaining objection from my reported regression. This follow-up is scoped to the recorded head and leaves the broader generic limitation separate. AI-assisted validation with independently executed compiler checks.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

(this as Writable<this>).field = ... no longer works in 5.9.0

3 participants