Skip to content

Latest commit

 

History

History
547 lines (420 loc) · 15.5 KB

prefer-immutable-types.md

File metadata and controls

547 lines (420 loc) · 15.5 KB

Require function parameters to be typed as certain immutability (functional/prefer-immutable-types)

💼🚫 This rule is enabled in the following configs: ☑️ lite, noMutations, ✅ recommended, 🔒 strict. This rule is disabled in the disableTypeChecked config.

🔧💡 This rule is automatically fixable by the --fix CLI option and manually fixable by editor suggestions.

💭 This rule requires type information.

Rule Details

This rule is designed to be a replacement for @typescript-eslint/prefer-readonly-parameter-types but also add extra functionality, allowing not just parameters to be checked.

This rule uses the is-immutable-type library to calculated immutability. This library allows for more powerful and customizable immutability enforcements to be made.

With parameters specifically, it is also worth noting that as immutable types are not assignable to mutable ones, and thus users will not be able to pass something like a readonly array to a functional that wants a mutable array; even if the function does not actually mutate said array. Libraries should therefore always enforce this rule for parameters.

Pure Functions

Ideally a pure function should always take immutable parameters and return a mutable value. This is because pure functions don't cause any side-effects, such as mutating the parameters; and they don't care what the caller then does with the returned value. However in practice this isn't often practical.

For example, take this snippet of code:

type Foo = { hello: number };
type Bar = { world: number };

function addBar(foo: Readonly<Foo>) {
  return {
    foo,
    bar: { world: 2 },
  };
}

const foobar = addBar({ hello: 1 });

Here the return type of addBar is shallowly mutable, but it's not deeply mutable as its foo property is immutable. To make it mutable, we'd need to deeply clone the contents of foo, but in many cases, and for many reasons, this would be a very bad thing to do. Simply casting foo to a mutable type can also lead to type issues later in your codebase.

It also worth noting that the above function isn't ideally typed. The return type of addBar will always state that foo is immutable, even if the caller passed in a mutable foo value. It's putting an extra constraint on the return type that shouldn't exits. A better typed version of this function would be as so:

function addBar<F extends Readonly<Foo>>(foo: F) {
  return {
    foo,
    bar: { world: 2 },
  };
}

❌ Incorrect

/* eslint functional/prefer-immutable-types: "error" */

function array1(arg: string[]) {} // array is not readonly
function array2(arg: ReadonlyArray<string[]>) {} // array element is not readonly
function array3(arg: [string, number]) {} // tuple is not readonly
function array4(arg: readonly [string[], number]) {} // tuple element is not readonly
// the above examples work the same if you use ReadonlyArray<T> instead

function object1(arg: { prop: string }) {} // property is not readonly
function object2(arg: { readonly prop: string; prop2: string }) {} // not all properties are readonly
function object3(arg: { readonly prop: { prop2: string } }) {} // nested property is not readonly
// the above examples work the same if you use Readonly<T> instead

interface CustomArrayType extends ReadonlyArray<string> {
  prop: string; // note: this property is mutable
}
function custom1(arg: CustomArrayType) {}

interface CustomFunction {
  (): void;
  prop: string; // note: this property is mutable
}
function custom2(arg: CustomFunction) {}

function union(arg: string[] | ReadonlyArray<number[]>) {} // not all types are readonly

// rule also checks function types
interface Foo1 {
  (arg: string[]): void;
}
interface Foo2 {
  new (arg: string[]): void;
}
const x = { foo(arg: string[]): void {} };
function foo(arg: string[]);
type Foo3 = (arg: string[]) => void;
interface Foo4 {
  foo(arg: string[]): void;
}

✅ Correct

/* eslint functional/prefer-immutable-types: "error" */

function array1(arg: ReadonlyArray<string>) {}
function array2(arg: ReadonlyArray<ReadonlyArray<string>>) {}
function array3(arg: readonly [string, number]) {}
function array4(arg: readonly [ReadonlyArray<string>, number]) {}
// the above examples work the same if you use ReadonlyArray<T> instead

function object1(arg: { readonly prop: string }) {}
function object2(arg: { readonly prop: string; readonly prop2: string }) {}
function object3(arg: { readonly prop: { readonly prop2: string } }) {}
// the above examples work the same if you use Readonly<T> instead

interface CustomArrayType extends ReadonlyArray<string> {
  readonly prop: string;
}
function custom1(arg: Readonly<CustomArrayType>) {}
// interfaces that extend the array types are not considered arrays, and thus must be made readonly.

interface CustomFunction {
  (): void;
  readonly prop: string;
}
function custom2(arg: CustomFunction) {}

function union(arg: ReadonlyArray<string> | ReadonlyArray<number[]>) {}

function primitive1(arg: string) {}
function primitive2(arg: number) {}
function primitive3(arg: boolean) {}
function primitive4(arg: unknown) {}
function primitive5(arg: null) {}
function primitive6(arg: undefined) {}
function primitive7(arg: any) {}
function primitive8(arg: never) {}
function primitive9(arg: string | number | undefined) {}

function fnSig(arg: () => void) {}

enum Foo {
  a,
  b,
}
function enum1(arg: Foo) {}

function symb1(arg: symbol) {}
const customSymbol = Symbol("a");
function symb2(arg: typeof customSymbol) {}

// function types
interface Foo1 {
  (arg: ReadonlyArray<string>): void;
}
interface Foo2 {
  new (arg: ReadonlyArray<string>): void;
}
const x = { foo(arg: ReadonlyArray<string>): void {} };
function foo(arg: ReadonlyArray<string>);
type Foo3 = (arg: ReadonlyArray<string>) => void;
interface Foo4 {
  foo(arg: ReadonlyArray<string>): void;
}

Settings

This rule can leverage shared settings to configure immutability settings.

See the immutability docs.

Options

This rule accepts an options object of the following type:

type Options = {
  enforcement: "None" | "ReadonlyShallow" | "ReadonlyDeep" | "Immutable";
  ignoreInferredTypes: boolean;
  ignoreClasses: boolean | "fieldsOnly";
  ignoreNamePattern?: string[] | string;
  ignoreTypePattern?: string[] | string;

  parameters?: {
    enforcement: "None" | "ReadonlyShallow" | "ReadonlyDeep" | "Immutable";
    ignoreInferredTypes: boolean;
    ignoreClasses: boolean | "fieldsOnly";
    ignoreNamePattern?: string[] | string;
    ignoreTypePattern?: string[] | string;
  };

  returnTypes?: {
    enforcement: "None" | "ReadonlyShallow" | "ReadonlyDeep" | "Immutable";
    ignoreInferredTypes: boolean;
    ignoreClasses: boolean | "fieldsOnly";
    ignoreNamePattern?: string[] | string;
    ignoreTypePattern?: string[] | string;
  };

  variables?: {
    enforcement: "None" | "ReadonlyShallow" | "ReadonlyDeep" | "Immutable";
    ignoreInFunctions: boolean;
    ignoreInferredTypes: boolean;
    ignoreClasses: boolean | "fieldsOnly";
    ignoreNamePattern?: string[] | string;
    ignoreTypePattern?: string[] | string;
  };

  fixer?: {
    ReadonlyShallow?:
      | { pattern: string; replace: string }
      | Array<{ pattern: string; replace: string }>;
    ReadonlyDeep?:
      | { pattern: string; replace: string }
      | Array<{ pattern: string; replace: string }>;
    Immutable?:
      | { pattern: string; replace: string }
      | Array<{ pattern: string; replace: string }>;
  };

  suggestions?: {
    ReadonlyShallow?: Array<
      Array<{ pattern: string; replace: string; message?: string }>
    >;
    ReadonlyDeep?: Array<
      Array<{ pattern: string; replace: string; message?: string }>
    >;
    Immutable?: Array<
      Array<{ pattern: string; replace: string; message?: string }>
    >;
  };

  overrides?: Array<{
    match: Array<
      | {
          from: "file";
          path?: string;
          name?: string | string[];
          pattern?: RegExp | RegExp[];
          ignoreName?: string | string[];
          ignorePattern?: RegExp | RegExp[];
        }
      | {
          from: "lib";
          name?: string | string[];
          pattern?: RegExp | RegExp[];
          ignoreName?: string | string[];
          ignorePattern?: RegExp | RegExp[];
        }
      | {
          from: "package";
          package?: string;
          name?: string | string[];
          pattern?: RegExp | RegExp[];
          ignoreName?: string | string[];
          ignorePattern?: RegExp | RegExp[];
        }
    >;
    options: Omit<Options, "overrides">;
    inherit?: boolean;
    disable: boolean;
  }>;
};

Default Options

const defaults = {
  enforcement: "Immutable",
  ignoreClasses: false,
  ignoreInferredTypes: false,
  fixer: false,
  suggestions: {
    ReadonlyShallow: [
      [
        {
          pattern:
            "^([_$a-zA-Z\\xA0-\\uFFFF][_$a-zA-Z0-9\\xA0-\\uFFFF]*\\[\\])$",
          replace: "readonly $1",
          message: "Prepend with readonly.",
        },
        {
          pattern: "^(Array|Map|Set)<(.+)>$",
          replace: "Readonly$1<$2>",
          message: "Use Readonly$1 instead of $1.",
        },
      ],
      [
        {
          pattern: "^(.+)$",
          replace: "Readonly<$1>",
          message: "Surround with Readonly.",
        },
      ],
    ],
  },
};

Preset Overrides

recommended

const recommendedOptions = {
  enforcement: "None",
  ignoreInferredTypes: true,
  parameters: {
    enforcement: "ReadonlyDeep",
  },
};

lite

const liteOptions = {
  enforcement: "None",
  ignoreInferredTypes: true,
  parameters: {
    enforcement: "ReadonlyShallow",
  },
};

enforcement

The level of immutability that should be enforced. One of the following:

  • None - Don't enforce any immutability.
  • ReadonlyShallow - Enforce that the data is shallowly immutable.
  • ReadonlyDeep - Enforce that the data is deeply immutable (methods may not be).
  • Immutable - Enforce that everything is deeply immutable, nothing can be modified.

❌ Incorrect

/* eslint functional/prefer-immutable-types: ["error", { "enforcement": "Immutable" }] */

function array(arg: ReadonlyArray<string>) {} // ReadonlyArray is not immutable
function set(arg: ReadonlySet<string>) {} // ReadonlySet is not immutable
function map(arg: ReadonlyMap<string>) {} // ReadonlyMap is not immutable

✅ Correct

/* eslint functional/prefer-immutable-types: ["error", { "enforcement": "Immutable" }] */

function set(arg: Readonly<ReadonlySet<string>>) {}
function map(arg: Readonly<ReadonlyMap<string>>) {}
function object(arg: Readonly<{ prop: string }>) {}
/* eslint functional/prefer-immutable-types: ["error", { "enforcement": "ReadonlyShallow" }] */

function array(arg: ReadonlyArray<{ foo: string }>) {}
function set(arg: ReadonlySet<{ foo: string }>) {}
function map(arg: ReadonlyMap<{ foo: string }>) {}
function object(arg: Readonly<{ prop: { foo: string } }>) {}

ignoreInferredTypes

This option allows you to ignore values which don't explicitly specify a type.

One case where this may be desirable is when an external dependency specifies a callback with mutable parameters, and manually annotating the callback's parameters is undesirable.

false by default.

ignoreClasses

A boolean to specify if checking classes should be ignored. false by default.

❌ Incorrect

/* eslint functional/prefer-immutable-types: ["error", { "ignoreInferredTypes": true }] */

import { acceptsCallback, type CallbackOptions } from "external-dependency";

acceptsCallback((options: CallbackOptions) => {});
external-dependency.d.ts
export interface CallbackOptions {
  readonly prop: string;
}
type Callback = (options: CallbackOptions) => void;
type AcceptsCallback = (callback: Callback) => void;

export const acceptsCallback: AcceptsCallback;

✅ Correct

/* eslint functional/prefer-immutable-types: ["error", { "ignoreInferredTypes": true }] */

import { acceptsCallback } from "external-dependency";

acceptsCallback((options) => {});
external-dependency.d.ts
export interface CallbackOptions {
  prop: string;
}
type Callback = (options: CallbackOptions) => void;
type AcceptsCallback = (callback: Callback) => void;

export const acceptsCallback: AcceptsCallback;

parameters.*, returnTypes.*, variables.*

Override the options specifically for the given type of types.

variables.ignoreInFunctions

If true, the rule will not flag any variables that are inside of function bodies.

fixer

Configure the fixer to work with your setup. If set to false, the fixer will be disabled.

fixer.*

Configure how the fixer should fix issue of each of the different enforcement levels.

suggestions

This is the same as fixer but for manual suggestions instead of automatic fixers. If set to false, the no suggestions will be enabled.

suggestions[*].*

Configure how the suggestion should fix issue of each of the different enforcement levels.

By default we only configure the suggestions to correct shallow readonly violations as TypeScript itself provides a utility type for this. If you have access to other utility types (such as type-fest's ReadonlyDeep ), you can configure the fixer to use them with this option.

Example using ReadonlyDeep instead of Readonly:

{
  // ...
  "suggestions": {
    "ReadonlyDeep": [
      [
        {
          "pattern": "^(?:Readonly<(.+)>|(.+))$",
          "replace": "ReadonlyDeep<$1$2>",
        },
      ],
    ],
  },
}

ignoreNamePattern

This option takes a RegExp string or an array of RegExp strings. It allows for the ability to ignore violations based on the identifier (name) of node in question.

ignoreTypePattern

This option takes a RegExp string or an array of RegExp strings. It allows for the ability to ignore violations based on the type (as written, with whitespace removed) of the node in question.

overrides

Allows for applying overrides to the options based on the type's declaration. This can be used to override the settings for types coming from 3rd party libraries.

Note: Only the first matching override will be used.

overrides[n].specifiers

A specifier, or an array of specifiers to match the function type against.

In the case of reference types, both the type and its generics will be recursively checked. If any of them match, the specifier will be considered a match.

overrides[n].options

The options to use when a specifiers matches.

overrides[n].inherit

Inherit the root options? Default is true.

overrides[n].disable

If true, when a specifier matches, this rule will not be applied to the matching node.