Skip to content

[readonly-type] "generic" autofix strips nested readonly modifiers, silently changing the type #1092

Description

@noshiro-pf

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.

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