Writable: Fix behavior with index signatures#1470
Merged
sindresorhus merged 5 commits intoJul 13, 2026
Merged
Conversation
When no `Keys` argument is passed, `Writable<T>` rebuilt the object via
`Except<T, keyof T> & Pick<...>`, which does not faithfully preserve the
input structure — most visibly, a `readonly` index signature came back
with a spurious numeric index signature added
(`{[x: string]: number; [x: number]: number}`).
Use a plain homomorphic mapped type for the default (all-keys) case, as
suggested by the maintainer, so only the `readonly` modifier is removed
and the shape (including index signatures) is preserved. The explicit
subset-of-`Keys` path is unchanged.
Fixes sindresorhus#717
sindresorhus
approved these changes
Jul 1, 2026
som-sm
requested changes
Jul 1, 2026
| // Pick just the keys that are not writable from the base type. | ||
| Except<BaseType, Keys> | ||
| // Pick the keys that should be writable from the base type and make them writable by removing the `readonly` modifier from the key. | ||
| & {-readonly [KeyType in keyof Pick<BaseType, Keys>]: Pick<BaseType, Keys>[KeyType]} |
Collaborator
There was a problem hiding this comment.
A better fix here would have been to change the mapped type after & to this:
& {-readonly [KeyType in keyof BaseType as KeyType extends Keys ? KeyType : never]: BaseType[KeyType]}Passes both the newly added test cases.
Contributor
Author
There was a problem hiding this comment.
Good call, that's cleaner — applied in 7a49338. Both new test cases and the full suite pass with it.
Writable: Only strip readonly without altering the structureWritable: Fix behavior with index signatures
som-sm
approved these changes
Jul 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #717.
Problem
When
Writableis used without the optionalKeysargument, it doesn't only stripreadonly— it also changes the object's structure. The clearest symptom is an index signature:Writable<{readonly [key: string]: number}>currently resolves to{[x: string]: number; [x: number]: number}— a spurious numeric index signature gets added.This happens because the default (all-keys) case still goes through the
Except<BaseType, Keys> & {-readonly [K in keyof Pick<BaseType, Keys>]: …}rebuild, and thatExcept/Pickround-trip does not faithfully reproduce the original shape.Fix
As suggested in the issue thread ("add an overload when that parameter is not passed and use the simple handling"), the default case now uses a plain homomorphic mapped type:
This only removes the
readonlymodifier and otherwise preserves the input structure exactly, including index signatures — matching the simpler community implementations referenced in the issue. The explicit subset-of-Keyspath (Writable<T, 'a' | 'b'>) is left completely unchanged, so all existing behavior and tests are preserved.The all-keys case is detected with
IsEqual<Keys, keyof BaseType>(already used internally byExcept), which istrueboth whenKeysis omitted and when the full key set is passed explicitly — semantically identical intents.Tests
Added two
tsdassertions totest-d/writable.ts:Writable<{readonly [key: string]: number}>→{[key: string]: number}Writable<{readonly [key: string]: number; readonly foo: number}>→{[key: string]: number; foo: number}Both fail on the current code (the result carries the extra
[x: number]: numberindex signature) and pass with the fix. Verified withgit stashon the source change. The fullnpm test(tsc+tsd+xo+ linter) passes.