Bug Report
In "generic" mode, the readonly-type autofix removes readonly modifiers from nested type literals without wrapping them in Readonly<>. Because Readonly<T> is shallow, the fixed type is not equivalent to the input: properties that were read-only silently become mutable.
This is a correctness change rather than a formatting nit — --fix rewrites a correct type declaration into a different, weaker one, and no diagnostic remains afterwards to indicate it happened. We hit it on a type-level test fixture, where it quietly invalidated the assertion the fixture existed to make.
Expected behavior
Every nested literal that loses its readonly modifiers gets a Readonly<> wrapper of its own:
type T = { readonly a: { readonly x: number } };
// -> type T = Readonly<{ a: Readonly<{ x: number }> }>;
Actual behavior
The nested modifiers are dropped and nothing is wrapped:
type T = { readonly a: { readonly x: number } };
// -> type T = Readonly<{ a: { x: number } }>; // `x` is now mutable
Full matrix (❌ = not type-preserving):
|
input |
actual output |
expected |
| ❌ |
{ readonly a: { readonly x: number } } |
Readonly<{ a: { x: number } }> |
Readonly<{ a: Readonly<{ x: number }> }> |
| ❌ |
{ readonly a: { readonly b: { readonly c: number } } } |
Readonly<{ a: { b: { c: number } } }> |
Readonly<{ a: Readonly<{ b: Readonly<{ c: number }> }> }> |
| ❌ |
Readonly<{ a: { readonly x: number } }> |
Readonly<{ a: { x: number } }> |
Readonly<{ a: Readonly<{ x: number }> }> |
| ✅ |
{ a: { readonly x: number } } |
{ a: Readonly<{ x: number }> } |
same |
| ✅ |
Readonly<{ readonly x: number }> |
Readonly<{ x: number }> |
same (genuinely redundant) |
| ✅ |
Readonly<{ a: ReadonlyArray<{ readonly x: 1 }> }> |
Readonly<{ a: ReadonlyArray<Readonly<{ x: 1 }>> }> |
same |
The last three rows are hopefully the useful diagnostic:
- Stripping
readonly is correct when the literal is the direct type argument of the Readonly<> (row 5) — there the modifier really is redundant.
- The bug is that a literal appearing as a property's type inside that
Readonly<> is treated the same way (rows 1–3), even though Readonly<> is shallow and does not cover it.
- Row 6 shows the misclassification disappears as soon as another type reference (
ReadonlyArray<>) sits between the literal and the Readonly<> — which suggests the ancestor lookup does not check that the literal is the direct type argument.
Steps to reproduce
import parser from '@typescript-eslint/parser';
import { Linter } from 'eslint';
import functional from 'eslint-plugin-functional';
const linter = new Linter();
const config = {
languageOptions: {
parser,
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
},
plugins: { functional },
rules: { 'functional/readonly-type': ['error', 'generic'] },
};
for (const code of [
'type T = { readonly a: { readonly x: number } };',
'type T = { readonly a: { readonly b: { readonly c: number } } };',
'type T = Readonly<{ a: { readonly x: number } }>;',
'type T = { a: { readonly x: number } };',
'type T = Readonly<{ readonly x: number }>;',
'type T = Readonly<{ a: ReadonlyArray<{ readonly x: 1 }> }>;',
]) {
console.log('IN :', code);
console.log('OUT:', linter.verifyAndFix(code, config).output, '\n');
}
Reproduced with only functional/readonly-type enabled, so no interaction with other rules or shared configs is involved.
Environment
|
|
eslint-plugin-functional |
10.0.0 (latest) |
eslint |
10.8.0 |
@typescript-eslint/parser |
8.65.0 |
typescript |
6.0.3 |
| Node.js |
26.3.1 |
Proposed changes
I have not written a patch, but here is what the multipass fixer does — applying one non-overlapping fix batch per pass, the way ESLint's SourceCodeFixer does:
pass 1
in : type T = { readonly a: { readonly x: number } };
fix: {"range":[9,47],"text":"Readonly<{ a: { readonly x: number } }>"}
out: type T = Readonly<{ a: { readonly x: number } }>;
pass 2
in : type T = Readonly<{ a: { readonly x: number } }>;
fix: {"range":[25,43],"text":"x: number"} <-- strips `readonly`, adds no wrapper
out: type T = Readonly<{ a: { x: number } }>;
Pass 1 is correct: the outer literal is wrapped and the inner readonly is carried over verbatim. In pass 1 the inner literal is also reported, with the correct fix (range [23,45] -> "Readonly<{ x: number }>"), but it overlaps the outer fix and ESLint defers it.
By pass 2 the inner literal is transitively inside a Readonly<>, so it gets re-classified as redundant rather than needs wrapping, and the emitted fix only deletes the readonly keyword. The wrap that was queued in pass 1 never happens.
That also explains why a nested literal is required to trigger it: with a single level ({ readonly a: number }) there is no second report to be re-classified.
So the fix is presumably in whatever decides "this literal is already inside a Readonly<>" — it would need to hold only when the literal is the direct type argument of that Readonly<>, not when it is reached through a property signature. Happy to test a patch against our repo if that helps.
Bug Report
In
"generic"mode, thereadonly-typeautofix removesreadonlymodifiers from nested type literals without wrapping them inReadonly<>. BecauseReadonly<T>is shallow, the fixed type is not equivalent to the input: properties that were read-only silently become mutable.This is a correctness change rather than a formatting nit —
--fixrewrites a correct type declaration into a different, weaker one, and no diagnostic remains afterwards to indicate it happened. We hit it on a type-level test fixture, where it quietly invalidated the assertion the fixture existed to make.Expected behavior
Every nested literal that loses its
readonlymodifiers gets aReadonly<>wrapper of its own:Actual behavior
The nested modifiers are dropped and nothing is wrapped:
Full matrix (❌ = not type-preserving):
{ readonly a: { readonly x: number } }Readonly<{ a: { x: number } }>Readonly<{ a: Readonly<{ x: number }> }>{ readonly a: { readonly b: { readonly c: number } } }Readonly<{ a: { b: { c: number } } }>Readonly<{ a: Readonly<{ b: Readonly<{ c: number }> }> }>Readonly<{ a: { readonly x: number } }>Readonly<{ a: { x: number } }>Readonly<{ a: Readonly<{ x: number }> }>{ a: { readonly x: number } }{ a: Readonly<{ x: number }> }Readonly<{ readonly x: number }>Readonly<{ x: number }>Readonly<{ a: ReadonlyArray<{ readonly x: 1 }> }>Readonly<{ a: ReadonlyArray<Readonly<{ x: 1 }>> }>The last three rows are hopefully the useful diagnostic:
readonlyis correct when the literal is the direct type argument of theReadonly<>(row 5) — there the modifier really is redundant.Readonly<>is treated the same way (rows 1–3), even thoughReadonly<>is shallow and does not cover it.ReadonlyArray<>) sits between the literal and theReadonly<>— which suggests the ancestor lookup does not check that the literal is the direct type argument.Steps to reproduce
Reproduced with only
functional/readonly-typeenabled, so no interaction with other rules or shared configs is involved.Environment
eslint-plugin-functionaleslint@typescript-eslint/parsertypescriptProposed changes
I have not written a patch, but here is what the multipass fixer does — applying one non-overlapping fix batch per pass, the way ESLint's
SourceCodeFixerdoes:Pass 1 is correct: the outer literal is wrapped and the inner
readonlyis carried over verbatim. In pass 1 the inner literal is also reported, with the correct fix (range [23,45] -> "Readonly<{ x: number }>"), but it overlaps the outer fix and ESLint defers it.By pass 2 the inner literal is transitively inside a
Readonly<>, so it gets re-classified as redundant rather than needs wrapping, and the emitted fix only deletes thereadonlykeyword. The wrap that was queued in pass 1 never happens.That also explains why a nested literal is required to trigger it: with a single level (
{ readonly a: number }) there is no second report to be re-classified.So the fix is presumably in whatever decides "this literal is already inside a
Readonly<>" — it would need to hold only when the literal is the direct type argument of thatReadonly<>, not when it is reached through a property signature. Happy to test a patch against our repo if that helps.