From a6a57a9b612127db0bbbe0ebe5fa0a45e46a4b55 Mon Sep 17 00:00:00 2001 From: Rebecca Stevens Date: Wed, 12 Jan 2022 22:17:33 +1300 Subject: [PATCH] feat(prefer-readonly-return-types): create new rule --- .eslintrc.json | 7 + README.md | 13 +- docs/rules/prefer-readonly-return-types.md | 268 +++++++++++++++++ package.json | 1 + src/common/ignore-options.ts | 29 ++ src/configs/all.ts | 1 + src/rules/index.ts | 2 + src/rules/prefer-readonly-return-types.ts | 158 ++++++++++ src/util/rule.ts | 23 ++ .../index.test.ts | 6 + .../prefer-readonly-return-types/ts/index.ts | 7 + .../ts/invalid.ts | 282 ++++++++++++++++++ .../prefer-readonly-return-types/ts/valid.ts | 153 ++++++++++ yarn.lock | 2 +- 14 files changed, 945 insertions(+), 7 deletions(-) create mode 100644 docs/rules/prefer-readonly-return-types.md create mode 100644 src/rules/prefer-readonly-return-types.ts create mode 100644 tests/rules/prefer-readonly-return-types/index.test.ts create mode 100644 tests/rules/prefer-readonly-return-types/ts/index.ts create mode 100644 tests/rules/prefer-readonly-return-types/ts/invalid.ts create mode 100644 tests/rules/prefer-readonly-return-types/ts/valid.ts diff --git a/.eslintrc.json b/.eslintrc.json index 799a4a33f..8936a30d2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -90,6 +90,13 @@ "jsdoc/require-jsdoc": "off" } }, + { + "files": ["**/*.md/**"], + "rules": { + "@typescript-eslint/array-type": "off", + "functional/no-mixed-type": "off" + } + }, // FIXME: This override is defined in the upsteam; it shouldn't need to be redefined here. Why? { "files": ["./**/*.md/**"], diff --git a/README.md b/README.md index 8ee950803..a8bb0cfa1 100644 --- a/README.md +++ b/README.md @@ -186,12 +186,13 @@ The [below section](#supported-rules) gives details on which rules are enabled b :see_no_evil: = `no-mutations` Ruleset. -| Name | Description | :see_no_evil: | :hear_no_evil: | :speak_no_evil: | :wrench: | :blue_heart: | -| -------------------------------------------------------------- | -------------------------------------------------------------------------- | :---------------------------------------------: | :--------------------------------------: | :----------------------------------------------: | :------: | :---------------: | -| [`immutable-data`](./docs/rules/immutable-data.md) | Disallow mutating objects and arrays | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | :blue_heart: | -| [`no-let`](./docs/rules/no-let.md) | Disallow mutable variables | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | | -| [`no-method-signature`](./docs/rules/no-method-signature.md) | Enforce property signatures with readonly modifiers over method signatures | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | :thought_balloon: | -| [`prefer-readonly-type`](./docs/rules/prefer-readonly-type.md) | Use readonly types and readonly modifiers where possible | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :wrench: | :thought_balloon: | +| Name | Description | :see_no_evil: | :hear_no_evil: | :speak_no_evil: | :wrench: | :blue_heart: | +| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | :---------------------------------------------: | :--------------------------------------: | :----------------------------------------------: | :------: | :---------------: | +| [`immutable-data`](./docs/rules/immutable-data.md) | Disallow mutating objects and arrays | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | :blue_heart: | +| [`no-let`](./docs/rules/no-let.md) | Disallow mutable variables | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | | +| [`no-method-signature`](./docs/rules/no-method-signature.md) | Enforce property signatures with readonly modifiers over method signatures | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | :thought_balloon: | +| [`prefer-readonly-type`](./docs/rules/prefer-readonly-type.md) | Use readonly types and readonly modifiers where possible | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :wrench: | :thought_balloon: | +| [`prefer-readonly-return-types`](./docs/rules/prefer-readonly-return-types.md) | Enforce use of readonly types for function return values | | | | | :thought_balloon: | ### No Object-Orientation Rules diff --git a/docs/rules/prefer-readonly-return-types.md b/docs/rules/prefer-readonly-return-types.md new file mode 100644 index 000000000..939c5ca4e --- /dev/null +++ b/docs/rules/prefer-readonly-return-types.md @@ -0,0 +1,268 @@ +# Requires that function return values are typed as readonly (prefer-readonly-return-types) + +This rules work just like [prefer-readonly-parameter-types](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/prefer-readonly-parameter-types.md). + +This rule should only be used in a purely functional environment to help ensure that values are not being mutated. + +## Rule Details + +This rule allows you to enforce that function return values resolve to a readonly type. + +Examples of **incorrect** code for this rule: + + + +```ts +/* eslint functional/prefer-readonly-type-declaration: "error" */ + +function array1(): string[] {} // array is not readonly +function array2(): readonly string[][] {} // array element is not readonly +function array3(): [string, number] {} // tuple is not readonly +function array4(): readonly [string[], number] {} // tuple element is not readonly +// the above examples work the same if you use ReadonlyArray instead + +function object1(): { prop: string } {} // property is not readonly +function object2(): { readonly prop: string; prop2: string } {} // not all properties are readonly +function object3(): { readonly prop: { prop2: string } } {} // nested property is not readonly +// the above examples work the same if you use Readonly instead + +interface CustomArrayType extends ReadonlyArray { + prop: string; // note: this property is mutable +} +function custom1(): CustomArrayType {} + +interface CustomFunction { + (): void; + prop: string; // note: this property is mutable +} +function custom2(): CustomFunction {} + +function union(): string[] | ReadonlyArray {} // not all types are readonly + +// rule also checks function types +interface Foo { + (): string[]; +} +interface Foo { + new (): string[]; +} +const x = { foo(): string[]; }; +function foo(): string[]; +type Foo = () => string[]; +interface Foo { + foo(): string[]; +} +``` + +Examples of **correct** code for this rule: + +```ts +/* eslint functional/prefer-readonly-return-types: "error" */ + +function array1(): readonly string[] {} +function array2(): readonly (readonly string[])[] {} +function array3(): readonly [string, number] {} +function array4(): readonly [readonly string[], number] {} +// the above examples work the same if you use ReadonlyArray instead + +function object1(): { readonly prop: string } {} +function object2(): { readonly prop: string; readonly prop2: string } {} +function object3(): { readonly prop: { readonly prop2: string } } {} +// the above examples work the same if you use Readonly instead + +interface CustomArrayType extends ReadonlyArray { + readonly prop: string; +} +function custom1(): Readonly {} +// interfaces that extend the array types are not considered arrays, and thus must be made readonly. + +interface CustomFunction { + (): void; + readonly prop: string; +} +function custom2(): CustomFunction {} + +function union(): readonly string[] | ReadonlyArray {} + +function primitive1(): string {} +function primitive2(): number {} +function primitive3(): boolean {} +function primitive4(): unknown {} +function primitive5(): null {} +function primitive6(): undefined {} +function primitive7(): any {} +function primitive8(): never {} +function primitive9(): number | string | undefined {} + +function fnSig(): () => void {} + +enum Foo { A, B } +function enum1(): Foo {} + +function symb1(): symbol {} +const customSymbol = Symbol('a'); +function symb2(): typeof customSymbol {} + +// function types +interface Foo { + (): readonly string[]; +} +interface Foo { + new (): readonly string[]; +} +const x = { foo(): readonly string[]; }; +function foo(): readonly string[]; +type Foo = () => readonly string[]; +interface Foo { + foo(): readonly string[]; +} +``` + +The default options: + +```ts +const defaults = { + allowLocalMutation: false, + ignoreClass: false, + ignoreCollections: false, + ignoreInferredTypes: false, + ignoreInterface: false, + treatMethodsAsReadonly: false, +} +``` + +### `treatMethodsAsReadonly` + +This option allows you to treat all mutable methods as though they were readonly. This may be desirable in when you are never reassigning methods. + +Examples of **incorrect** code for this rule with `{treatMethodsAsReadonly: false}`: + +```ts +type MyType = { + readonly prop: string; + method(): string; // note: this method is mutable +}; +function foo(arg: MyType) {} +``` + +Examples of **correct** code for this rule with `{treatMethodsAsReadonly: false}`: + +```ts +type MyType = Readonly<{ + prop: string; + method(): string; +}>; +function foo(): MyType {} +type MyOtherType = { + readonly prop: string; + readonly method: () => string; +}; +function bar(): MyOtherType {} +``` + +Examples of **correct** code for this rule with `{treatMethodsAsReadonly: true}`: + +```ts +type MyType = { + readonly prop: string; + method(): string; // note: this method is mutable +}; +function foo(): MyType {} +``` + +### `ignoreClass` + +If set, classes will not be checked. + +Examples of **incorrect** code for the `{ "ignoreClass": false }` option: + + + +```ts +/* eslint functional/prefer-readonly-type-declaration: ["error", { "ignoreClass": false }] */ + +class { + myprop: string; +} +``` + +Examples of **correct** code for the `{ "ignoreClass": true }` option: + +```ts +/* eslint functional/prefer-readonly-type-declaration: ["error", { "ignoreClass": true }] */ + +class { + myprop: string; +} +``` + +### `ignoreInterface` + +If set, interfaces will not be checked. + +Examples of **incorrect** code for the `{ "ignoreInterface": false }` option: + + + +```ts +/* eslint functional/prefer-readonly-type-declaration: ["error", { "ignoreInterface": false }] */ + +interface I { + myprop: string; +} +``` + +Examples of **correct** code for the `{ "ignoreInterface": true }` option: + +```ts +/* eslint functional/prefer-readonly-type-declaration: ["error", { "ignoreInterface": true }] */ + +interface I { + myprop: string; +} +``` + +### `ignoreCollections` + +If set, collections (Array, Tuple, Set, and Map) will not be required to be readonly when used outside of type aliases and interfaces. + +Examples of **incorrect** code for the `{ "ignoreCollections": false }` option: + + + +```ts +/* eslint functional/prefer-readonly-type-declaration: ["error", { "ignoreCollections": false }] */ + +const foo: number[] = []; +const bar: [string, string] = ["foo", "bar"]; +const baz: Set = new Set(); +const qux: Map = new Map(); +``` + +Examples of **correct** code for the `{ "ignoreCollections": true }` option: + +```ts +/* eslint functional/prefer-readonly-type-declaration: ["error", { "ignoreCollections": true }] */ + +const foo: number[] = []; +const bar: [string, string] = ["foo", "bar"]; +const baz: Set = new Set(); +const qux: Map = new Map(); +``` + +### `ignoreInferredTypes` + +This option allows you to ignore types that aren't explicitly specified. + +### `allowLocalMutation` + +See the [allowLocalMutation](./options/allow-local-mutation.md) docs. + +### `ignorePattern` + +Use the given regex pattern(s) to match against the type's name (for objects this is the property's name not the object's name). + +Note: If using this option to require mutable properties are marked as mutable via a naming convention (e.g. `{ "ignorePattern": "^[Mm]utable.+" }`), +type aliases and interfaces names will still need to comply with the `readonlyAliasPatterns` and `mutableAliasPatterns` options. + +See the [ignorePattern](./options/ignore-pattern.md) docs for more info. diff --git a/package.json b/package.json index 4b97a96a7..ddfd9e648 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ }, "dependencies": { "@typescript-eslint/utils": "^5.10.0", + "@typescript-eslint/type-utils": "^5.10.0", "deepmerge-ts": "^2.0.1", "escape-string-regexp": "^4.0.0" }, diff --git a/src/common/ignore-options.ts b/src/common/ignore-options.ts index 2759d7205..97a6db9d8 100644 --- a/src/common/ignore-options.ts +++ b/src/common/ignore-options.ts @@ -125,6 +125,22 @@ export const ignoreInterfaceOptionSchema: JSONSchema4["properties"] = { }, }; +/** + * The option to ignore inferred types. + */ +export type IgnoreInferredTypesOption = { + readonly ignoreInferredTypes: boolean; +}; + +/** + * The schema for the option to ignore inferred types. + */ +export const ignoreInferredTypesOptionSchema: JSONSchema4["properties"] = { + ignoreInferredTypes: { + type: "boolean", + }, +}; + /** * Get the identifier text of the given node. */ @@ -322,6 +338,19 @@ export function shouldIgnoreInterface( return ignoreInterface === true && inInterface(node); } +/** + * Should the given node be allowed base off the following rule options? + * + * - IgnoreInterfaceOption. + */ +export function shouldIgnoreInferredTypes( + node: ReadonlyDeep, + context: ReadonlyDeep>, + { ignoreInferredTypes }: Partial +) { + return ignoreInferredTypes === true && node === null; +} + /** * Should the given node be allowed base off the following rule options? * diff --git a/src/configs/all.ts b/src/configs/all.ts index 0db73d9d0..952b37034 100644 --- a/src/configs/all.ts +++ b/src/configs/all.ts @@ -21,6 +21,7 @@ const config: Linter.Config = { rules: { "functional/no-method-signature": "error", "functional/no-mixed-type": "error", + "functional/prefer-readonly-return-types": "error", "functional/prefer-readonly-type": "error", "functional/prefer-tacit": ["error", { assumeTypes: false }], "functional/no-return-void": "error", diff --git a/src/rules/index.ts b/src/rules/index.ts index 06eb7baef..ea40af84d 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -12,6 +12,7 @@ import * as noReturnVoid from "./no-return-void"; import * as noThisExpression from "./no-this-expression"; import * as noThrowStatement from "./no-throw-statement"; import * as noTryStatement from "./no-try-statement"; +import * as preferReadonlyReturnTypes from "./prefer-readonly-return-types"; import * as preferReadonlyTypes from "./prefer-readonly-type"; import * as preferTacit from "./prefer-tacit"; @@ -33,6 +34,7 @@ export const rules = { [noThisExpression.name]: noThisExpression.rule, [noThrowStatement.name]: noThrowStatement.rule, [noTryStatement.name]: noTryStatement.rule, + [preferReadonlyReturnTypes.name]: preferReadonlyReturnTypes.rule, [preferReadonlyTypes.name]: preferReadonlyTypes.rule, [preferTacit.name]: preferTacit.rule, }; diff --git a/src/rules/prefer-readonly-return-types.ts b/src/rules/prefer-readonly-return-types.ts new file mode 100644 index 000000000..d80717862 --- /dev/null +++ b/src/rules/prefer-readonly-return-types.ts @@ -0,0 +1,158 @@ +import type { ReadonlynessOptions } from "@typescript-eslint/type-utils"; +import { + readonlynessOptionsDefaults, + readonlynessOptionsSchema, +} from "@typescript-eslint/type-utils"; +import type { ESLintUtils, TSESLint, TSESTree } from "@typescript-eslint/utils"; +import { deepmerge } from "deepmerge-ts"; +import type { JSONSchema4 } from "json-schema"; +import type { ReadonlyDeep } from "type-fest"; + +import type { + AllowLocalMutationOption, + IgnoreClassOption, + IgnoreInferredTypesOption, + IgnoreInterfaceOption, + IgnorePatternOption, +} from "~/common/ignore-options"; +import { + allowLocalMutationOptionSchema, + ignoreClassOptionSchema, + ignoreInferredTypesOptionSchema, + ignoreInterfaceOptionSchema, + ignorePatternOptionSchema, + shouldIgnoreClass, + shouldIgnoreInferredTypes, + shouldIgnoreInterface, + shouldIgnoreLocalMutation, + shouldIgnorePattern, +} from "~/common/ignore-options"; +import type { RuleResult } from "~/util/rule"; +import { isReadonly, createRule } from "~/util/rule"; +import { isFunctionLike, isTSFunctionType } from "~/util/typeguard"; + +/** + * The name of this rule. + */ +export const name = "prefer-readonly-return-types" as const; + +/** + * The options this rule can take. + */ +type Options = readonly [ + AllowLocalMutationOption & + IgnoreClassOption & + IgnoreInferredTypesOption & + IgnoreInterfaceOption & + IgnorePatternOption & + ReadonlynessOptions +]; + +/** + * The schema for the rule options. + */ +const schema: JSONSchema4 = [ + { + type: "object", + properties: deepmerge( + allowLocalMutationOptionSchema, + ignoreClassOptionSchema, + ignoreInferredTypesOptionSchema, + ignoreInterfaceOptionSchema, + ignorePatternOptionSchema, + readonlynessOptionsSchema.properties + ), + additionalProperties: false, + }, +]; + +/** + * The default options for the rule. + */ +const defaultOptions: Options = [ + { + ignoreClass: false, + ignoreInterface: false, + allowLocalMutation: false, + ignoreInferredTypes: false, + ...readonlynessOptionsDefaults, + }, +]; + +/** + * The possible error messages. + */ +const errorMessages = { + returnTypeShouldBeReadonly: "Return type should be readonly.", +} as const; + +/** + * The meta data for this rule. + */ +const meta: ESLintUtils.NamedCreateRuleMeta = { + type: "suggestion", + docs: { + description: "Prefer readonly return types over mutable one.", + recommended: "error", + }, + messages: errorMessages, + fixable: "code", + schema, +}; + +/** + * Check if the given TypeAnnotation violates this rule. + */ +function checkTypeAnnotation( + node: ReadonlyDeep, + context: ReadonlyDeep< + TSESLint.RuleContext + >, + [optionsObject]: Options +): RuleResult { + if ( + !isReturnType(node) || + shouldIgnoreInferredTypes(node.typeAnnotation, context, optionsObject) || + shouldIgnoreClass(node.typeAnnotation, context, optionsObject) || + shouldIgnoreInterface(node.typeAnnotation, context, optionsObject) || + shouldIgnoreLocalMutation(node.typeAnnotation, context, optionsObject) || + shouldIgnorePattern(node.typeAnnotation, context, optionsObject) || + isReadonly(node.typeAnnotation, context, optionsObject) + ) { + return { + context, + descriptors: [], + }; + } + + return { + context, + descriptors: [ + { + node: node.typeAnnotation, + messageId: "returnTypeShouldBeReadonly", + }, + ], + }; +} + +/** + * Is the given node a return type? + */ +function isReturnType(node: ReadonlyDeep) { + return ( + node.parent !== undefined && + (isFunctionLike(node.parent) || isTSFunctionType(node.parent)) && + node.parent.returnType === node + ); +} + +// Create the rule. +export const rule = createRule( + name, + meta, + defaultOptions, + { + TSTypeAnnotation: checkTypeAnnotation, + } +); diff --git a/src/util/rule.ts b/src/util/rule.ts index 6042ef035..621109fd1 100644 --- a/src/util/rule.ts +++ b/src/util/rule.ts @@ -1,3 +1,5 @@ +import type { ReadonlynessOptions } from "@typescript-eslint/type-utils"; +import { isTypeReadonly } from "@typescript-eslint/type-utils"; import type { TSESLint, TSESTree } from "@typescript-eslint/utils"; import { ESLintUtils } from "@typescript-eslint/utils"; import type { Rule } from "eslint"; @@ -132,6 +134,27 @@ export function getTypeOfNode< return constrained ?? nodeType; } +/** + * Is the given node readonly? + */ +export function isReadonly< + Context extends ReadonlyDeep> +>( + node: ReadonlyDeep, + context: Context, + readonlynessOptions: ReadonlyDeep +): boolean { + const { parserServices } = context; + + if (parserServices === undefined || parserServices.program === undefined) { + return false; + } + + const checker = parserServices.program.getTypeChecker(); + const type = getTypeOfNode(node, context); + return isTypeReadonly(checker, type!, readonlynessOptions); +} + /** * Get the es tree node from the given ts node. */ diff --git a/tests/rules/prefer-readonly-return-types/index.test.ts b/tests/rules/prefer-readonly-return-types/index.test.ts new file mode 100644 index 000000000..ae02f22c3 --- /dev/null +++ b/tests/rules/prefer-readonly-return-types/index.test.ts @@ -0,0 +1,6 @@ +import { name, rule } from "~/rules/prefer-readonly-return-types"; +import { testUsing } from "~/tests/helpers/testers"; + +import tsTests from "./ts"; + +testUsing.typescript(name, rule, tsTests); diff --git a/tests/rules/prefer-readonly-return-types/ts/index.ts b/tests/rules/prefer-readonly-return-types/ts/index.ts new file mode 100644 index 000000000..40a005f71 --- /dev/null +++ b/tests/rules/prefer-readonly-return-types/ts/index.ts @@ -0,0 +1,7 @@ +import invalid from "./invalid"; +import valid from "./valid"; + +export default { + valid, + invalid, +}; diff --git a/tests/rules/prefer-readonly-return-types/ts/invalid.ts b/tests/rules/prefer-readonly-return-types/ts/invalid.ts new file mode 100644 index 000000000..61455aa1b --- /dev/null +++ b/tests/rules/prefer-readonly-return-types/ts/invalid.ts @@ -0,0 +1,282 @@ +import dedent from "dedent"; + +import type { InvalidTestCase } from "~/tests/helpers/util"; + +const tests: ReadonlyArray = [ + // Don't allow mutable return type. + { + code: dedent` + function foo(): Array {} + function bar(): number[] {} + `, + optionsSet: [[]], + errors: [ + { + messageId: "returnTypeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 17, + }, + { + messageId: "returnTypeShouldBeReadonly", + type: "TSArrayType", + line: 2, + column: 17, + }, + ], + }, + { + code: dedent` + const foo = function(): Array {} + const bar = function(): number[] {} + `, + optionsSet: [[]], + errors: [ + { + messageId: "returnTypeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 25, + }, + { + messageId: "returnTypeShouldBeReadonly", + type: "TSArrayType", + line: 2, + column: 25, + }, + ], + }, + { + code: dedent` + const foo = (): Array => {} + const bar = (): number[] => {} + `, + optionsSet: [[]], + errors: [ + { + messageId: "returnTypeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 17, + }, + { + messageId: "returnTypeShouldBeReadonly", + type: "TSArrayType", + line: 2, + column: 17, + }, + ], + }, + { + code: dedent` + class Foo { + foo(): Array {} + } + class Bar { + bar(): number[] {} + } + `, + optionsSet: [[]], + errors: [ + { + messageId: "returnTypeShouldBeReadonly", + type: "TSTypeReference", + line: 2, + column: 10, + }, + { + messageId: "returnTypeShouldBeReadonly", + type: "TSArrayType", + line: 5, + column: 10, + }, + ], + }, + // Interface with functions with mutable return types should fail. + { + code: dedent` + interface Foo { + a: () => string[], + } + `, + optionsSet: [[]], + errors: [ + { + messageId: "returnTypeShouldBeReadonly", + type: "TSArrayType", + line: 2, + column: 12, + }, + ], + }, + // Type aliases with functions with mutable return types should fail. + { + code: dedent` + type Foo = { + a: () => string[], + }; + `, + optionsSet: [[]], + errors: [ + { + messageId: "returnTypeShouldBeReadonly", + type: "TSArrayType", + line: 2, + column: 12, + }, + ], + }, + // Don't allow mutable return type with Type Arguments. + { + code: dedent` + function foo(): Promise> {} + function foo(): Promise {} + `, + optionsSet: [[]], + errors: [ + { + messageId: "returnTypeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 17, + }, + { + messageId: "returnTypeShouldBeReadonly", + type: "TSTypeReference", + line: 2, + column: 17, + }, + ], + }, + // Don't allow mutable return type with deep Type Arguments. + { + code: dedent` + type Foo = { readonly x: T; }; + function foo(): Promise>> {} + function bar(): Promise> {} + `, + optionsSet: [[]], + errors: [ + { + messageId: "returnTypeShouldBeReadonly", + type: "TSTypeReference", + line: 2, + column: 17, + }, + { + messageId: "returnTypeShouldBeReadonly", + type: "TSTypeReference", + line: 3, + column: 17, + }, + ], + }, + // Don't allow mutable Type Arguments in a tuple return type. + { + code: dedent` + function foo(): readonly [number, Array, number] {} + function bar(): readonly [number, number[], number] {} + `, + optionsSet: [[]], + errors: [ + { + messageId: "returnTypeShouldBeReadonly", + type: "TSTypeOperator", + line: 1, + column: 17, + }, + { + messageId: "returnTypeShouldBeReadonly", + type: "TSTypeOperator", + line: 2, + column: 17, + }, + ], + }, + // Don't allow mutable Union Type return type. + { + code: dedent` + function foo(): { readonly a: Array } | { readonly b: string[] } {} + `, + optionsSet: [[]], + errors: [ + { + messageId: "returnTypeShouldBeReadonly", + type: "TSUnionType", + line: 1, + column: 17, + }, + ], + }, + // Don't allow mutable Intersection Type return type. + { + code: dedent` + function foo(): { readonly a: Array } & { readonly b: string[] } {} + `, + optionsSet: [[]], + errors: [ + { + messageId: "returnTypeShouldBeReadonly", + type: "TSIntersectionType", + line: 1, + column: 17, + }, + ], + }, + // Don't allow mutable Conditional Type return type. + { + code: dedent` + function foo(): T extends Array ? string : number[] {} + `, + optionsSet: [[]], + errors: [ + { + messageId: "returnTypeShouldBeReadonly", + type: "TSConditionalType", + line: 1, + column: 20, + }, + ], + }, + // Mutable method signature should fail. + { + code: dedent` + type Foo = { + a: () => { + methodSignature(): string; + }, + } + `, + optionsSet: [[]], + errors: [ + { + messageId: "returnTypeShouldBeReadonly", + type: "TSTypeLiteral", + line: 2, + column: 12, + }, + ], + }, + { + code: dedent` + type Foo = { + a: () => { + methodSignature1(): string; + } | { + methodSignature2(): number; + }, + }; + `, + optionsSet: [[]], + errors: [ + { + messageId: "returnTypeShouldBeReadonly", + type: "TSUnionType", + line: 2, + column: 12, + }, + ], + }, +]; + +export default tests; diff --git a/tests/rules/prefer-readonly-return-types/ts/valid.ts b/tests/rules/prefer-readonly-return-types/ts/valid.ts new file mode 100644 index 000000000..9b6a44914 --- /dev/null +++ b/tests/rules/prefer-readonly-return-types/ts/valid.ts @@ -0,0 +1,153 @@ +import dedent from "dedent"; + +import type { ValidTestCase } from "~/tests/helpers/util"; + +const tests: ReadonlyArray = [ + // Should not fail on shorthand syntax readonly array type as return type. + { + code: dedent` + function foo(): readonly number[] {} + `, + optionsSet: [[]], + }, + { + code: dedent` + const foo = (): readonly number[] => {} + `, + optionsSet: [[]], + }, + // Should not fail on longhand syntax readonly array type as return type. + { + code: dedent` + function foo(): ReadonlyArray {} + `, + optionsSet: [[]], + }, + { + code: dedent` + const foo = (): ReadonlyArray => {} + `, + optionsSet: [[]], + }, + // Allow inline immutable return type. + { + code: dedent` + function foo(bar: string): { readonly baz: number } {} + `, + optionsSet: [[]], + }, + // Interfaces with functions with immutable return types should not produce failures. + { + code: dedent` + interface Foo { + a: () => readonly string[], + } + `, + optionsSet: [[]], + }, + // Type aliases with functions with immutable return types should not produce failures. + { + code: dedent` + type Foo = { + a: () => readonly string[], + }; + `, + optionsSet: [[]], + }, + // Ignore Classes. + { + code: dedent` + class Klass { + a(): string[] {} + } + `, + optionsSet: [[{ ignoreClass: true }]], + }, + // Ignore Interfaces. + { + code: dedent` + interface Foo { + a: () => string[], + } + `, + optionsSet: [[{ ignoreInterface: true }]], + }, + // TODO: Allow Local. + // TODO: Ignore Prefix. + // TODO: Ignore Suffix. + // Readonly method signature. + { + code: dedent` + type Foo = { + a: () => Readonly<{ + methodSignature(): string; + }>, + } + `, + optionsSet: [[]], + }, + { + code: dedent` + type Foo = { + a: () => Readonly<{ + methodSignature1(): string; + }> | Readonly<{ + methodSignature2(): number; + }>, + }; + `, + optionsSet: [[]], + }, + // Mutable method signature treated as readonly. + { + code: dedent` + type Foo = { + a: () => { + methodSignature(): string; + }, + } + `, + optionsSet: [ + [ + { + treatMethodsAsReadonly: true, + }, + ], + ], + }, + { + code: dedent` + type Foo = { + a: () => { + methodSignature1(): string; + } | { + methodSignature2(): number; + }, + }; + `, + optionsSet: [ + [ + { + treatMethodsAsReadonly: true, + }, + ], + ], + }, + // Ignore inferred types. + { + code: dedent` + function foo() { + return [1, 2, 3] + } + `, + optionsSet: [ + [ + { + ignoreInferredTypes: true, + }, + ], + ], + }, +]; + +export default tests; diff --git a/yarn.lock b/yarn.lock index 4ee741e6d..a940e4db0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1541,7 +1541,7 @@ "@typescript-eslint/types" "5.10.0" "@typescript-eslint/visitor-keys" "5.10.0" -"@typescript-eslint/type-utils@5.10.0": +"@typescript-eslint/type-utils@5.10.0", "@typescript-eslint/type-utils@^5.10.0": version "5.10.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.10.0.tgz#8524b9479c19c478347a7df216827e749e4a51e5" integrity sha512-TzlyTmufJO5V886N+hTJBGIfnjQDQ32rJYxPaeiyWKdjsv2Ld5l8cbS7pxim4DeNs62fKzRSt8Q14Evs4JnZyQ==