diff --git a/docs/rules/prefer-readonly-type-declaration.md b/docs/rules/prefer-readonly-type-declaration.md new file mode 100644 index 000000000..36032ea92 --- /dev/null +++ b/docs/rules/prefer-readonly-type-declaration.md @@ -0,0 +1,207 @@ +# Prefer readonly types over mutable types (prefer-readonly-type-declaration) + +This rule enforces use of the readonly modifier and readonly types. + +## Rule Details + +This rule enforces use of `readonly T[]` (`ReadonlyArray`) over `T[]` (`Array`). + +The readonly modifier must appear on property signatures in interfaces, property declarations in classes, and index signatures. + +Examples of **incorrect** code for this rule: + +```ts +/* eslint functional/prefer-readonly-type-declaration: "error" */ + +interface Point { + x: number; + y: number; +} +const point: Point = { x: 23, y: 44 }; +point.x = 99; // This is perfectly valid. +``` + +Examples of **correct** code for this rule: + +```ts +/* eslint functional/prefer-readonly-type-declaration: "error" */ + +interface Point { + readonly x: number; + readonly y: number; +} +const point: Point = { x: 23, y: 44 }; +point.x = 99; // <- No object mutation allowed. +``` + +```ts +/* eslint functional/prefer-readonly-type-declaration: "error" */ + +interface Point { + readonly x: number; + readonly y: number; +} +const point: Point = { x: 23, y: 44 }; +const transformedPoint = { ...point, x: 99 }; +``` + +### Benefits of using the `readonly` modifier + +A variable declared as `const` can not be reassigned, however what's in the variable can be mutated. +This is why the `readonly` modifier exists. It prevents you from assigning a value to the result of a member expression. +This is just as effective as using `Object.freeze()` to prevent mutations. However the `readonly` modifier has **no run-time cost**, and is enforced at **compile time**. + +The `readonly` modifier also works on indexers: + +```ts +const foo: { readonly [key: string]: number } = { a: 1, b: 2 }; +foo["a"] = 3; // Error: Index signature only permits reading +``` + +### Benefits of using `readonly T[]` + +Even if an array is declared with `const` it is still possible to mutate the contents of the array. + +```ts +interface Point { + readonly x: number; + readonly y: number; +} +const points: Array = [{ x: 23, y: 44 }]; +points.push({ x: 1, y: 2 }); // This is perfectly valid. +``` + +Using the `ReadonlyArray` type or `readonly T[]` will stop this mutation: + +```ts +interface Point { + readonly x: number; + readonly y: number; +} + +const points: ReadonlyArray = [{ x: 23, y: 44 }]; +// const points: readonly Point[] = [{ x: 23, y: 44 }]; // This is the alternative syntax for the line above + +points.push({ x: 1, y: 2 }); // Unresolved method push() +``` + +## Options + +This rule accepts an options object of the following type: + +```ts +{ + allowLocalMutation: boolean; + allowMutableReturnType: boolean; + checkImplicit: boolean; + ignoreClass: boolean | "fieldsOnly"; + ignoreInterface: boolean; + ignoreCollections: boolean; + ignorePattern?: string | Array; +} +``` + +The default options: + +```ts +{ + allowLocalMutation: false, + allowMutableReturnType: true, + checkImplicit: false, + ignoreClass: false, + ignoreInterface: false, + ignoreCollections: false, +} +``` + +### `checkImplicit` + +By default, this function only checks explicit types. Enabling this option will make the rule also check implicit types. + +Note: Checking implicit types is more expensive (slow). + +### `allowMutableReturnType` + +Doesn't check the return type of functions. + +### `ignoreClass` + +A boolean to specify if checking for `readonly` should apply to classes. `false` by default. + +Examples of **incorrect** code for the `{ "ignoreClass": false }` option: + +```ts +/* eslint functional/readonly: ["error", { "ignoreClass": false }] */ + +class { + myprop: string; +} +``` + +Examples of **correct** code for the `{ "ignoreClass": true }` option: + +```ts +/* eslint functional/readonly: ["error", { "ignoreClass": true }] */ + +class { + myprop: string; +} +``` + +### `ignoreInterface` + +A boolean to specify if checking for `readonly` should apply to interfaces. `false` by default. + +Examples of **incorrect** code for the `{ "ignoreInterface": false }` option: + +```ts +/* eslint functional/readonly: ["error", { "ignoreInterface": false }] */ + +interface { + myprop: string; +} +``` + +Examples of **correct** code for the `{ "ignoreInterface": true }` option: + +```ts +/* eslint functional/readonly: ["error", { "ignoreInterface": true }] */ + +interface { + myprop: string; +} +``` + +### `ignoreCollections` + +A boolean to specify if checking for `readonly` should apply to mutable collections (Array, Tuple, Set, and Map). Helpful for migrating from tslint-immutable to this plugin. `false` by default. + +Examples of **incorrect** code for the `{ "ignoreCollections": false }` option: + +```ts +/* eslint functional/readonly: ["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/readonly: ["error", { "ignoreCollections": true }] */ + +const foo: number[] = []; +const bar: [string, string] = ["foo", "bar"]; +const baz: Set = new Set(); +const qux: Map = new Map(); +``` + +### `allowLocalMutation` + +See the [allowLocalMutation](./options/allow-local-mutation.md) docs. + +### `ignorePattern` + +See the [ignorePattern](./options/ignore-pattern.md) docs. diff --git a/src/configs/all.ts b/src/configs/all.ts index 0db73d9d0..690bc34d1 100644 --- a/src/configs/all.ts +++ b/src/configs/all.ts @@ -21,7 +21,7 @@ const config: Linter.Config = { rules: { "functional/no-method-signature": "error", "functional/no-mixed-type": "error", - "functional/prefer-readonly-type": "error", + "functional/prefer-readonly-type-declaration": "error", "functional/prefer-tacit": ["error", { assumeTypes: false }], "functional/no-return-void": "error", }, diff --git a/src/configs/no-mutations.ts b/src/configs/no-mutations.ts index bc5cc4600..acd249f7d 100644 --- a/src/configs/no-mutations.ts +++ b/src/configs/no-mutations.ts @@ -10,7 +10,7 @@ const config: Linter.Config = { files: ["*.ts", "*.tsx"], rules: { "functional/no-method-signature": "warn", - "functional/prefer-readonly-type": "error", + "functional/prefer-readonly-type-declaration": "error", }, }, ], diff --git a/src/rules/index.ts b/src/rules/index.ts index 06eb7baef..699147c16 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -13,6 +13,7 @@ import * as noThisExpression from "./no-this-expression"; import * as noThrowStatement from "./no-throw-statement"; import * as noTryStatement from "./no-try-statement"; import * as preferReadonlyTypes from "./prefer-readonly-type"; +import * as preferReadonlyTypesDeclaration from "./prefer-readonly-type-declaration"; import * as preferTacit from "./prefer-tacit"; /** @@ -34,5 +35,6 @@ export const rules = { [noThrowStatement.name]: noThrowStatement.rule, [noTryStatement.name]: noTryStatement.rule, [preferReadonlyTypes.name]: preferReadonlyTypes.rule, + [preferReadonlyTypesDeclaration.name]: preferReadonlyTypesDeclaration.rule, [preferTacit.name]: preferTacit.rule, }; diff --git a/src/rules/prefer-readonly-type-declaration.ts b/src/rules/prefer-readonly-type-declaration.ts new file mode 100644 index 000000000..7d041a8bd --- /dev/null +++ b/src/rules/prefer-readonly-type-declaration.ts @@ -0,0 +1,606 @@ +import type { TSESTree } from "@typescript-eslint/experimental-utils"; +import { all as deepMerge } from "deepmerge"; +import type { JSONSchema4 } from "json-schema"; + +import type { + AllowLocalMutationOption, + IgnoreClassOption, + IgnoreInterfaceOption, + IgnorePatternOption, +} from "~/common/ignore-options"; +import { + allowLocalMutationOptionSchema, + ignoreClassOptionSchema, + ignoreInterfaceOptionSchema, + ignorePatternOptionSchema, +} from "~/common/ignore-options"; +import type { RuleContext, RuleMetaData, RuleResult } from "~/util/rule"; +import { isReadonly, createRule } from "~/util/rule"; +import { + getParentIndexSignature, + getTypeDeclaration, + isInReturnType, +} from "~/util/tree"; +import { + isIdentifier, + isTSArrayType, + isTSIndexSignature, + isTSInterfaceDeclaration, + isTSParameterProperty, + isTSPropertySignature, + isTSTupleType, + isTSTypeAliasDeclaration, + isTSTypeOperator, +} from "~/util/typeguard"; + +// The name of this rule. +export const name = "prefer-readonly-type-declaration" as const; + +// The options this rule can take. +type Options = AllowLocalMutationOption & + IgnoreClassOption & + IgnoreInterfaceOption & + IgnorePatternOption & { + readonly allowMutableReturnType: boolean; + readonly ignoreCollections: boolean; + readonly aliases: { + readonly mustBeReadonly: { + readonly pattern: ReadonlyArray | string; + readonly requireOthersToBeMutable: boolean; + }; + readonly mustBeMutable: { + readonly pattern: ReadonlyArray | string; + readonly requireOthersToBeReadonly: boolean; + }; + readonly blacklist: ReadonlyArray | string; + }; + }; + +// The schema for the rule options. +const schema: JSONSchema4 = [ + deepMerge([ + allowLocalMutationOptionSchema, + ignorePatternOptionSchema, + ignoreClassOptionSchema, + ignoreInterfaceOptionSchema, + { + type: "object", + properties: { + allowMutableReturnType: { + type: "boolean", + }, + ignoreCollections: { + type: "boolean", + }, + aliases: { + type: "object", + properties: { + mustBeReadonly: { + type: "object", + properties: { + pattern: { + type: ["string", "array"], + items: { + type: "string", + }, + }, + requireOthersToBeMutable: { + type: "boolean", + }, + }, + additionalProperties: false, + }, + mustBeMutable: { + type: "object", + properties: { + pattern: { + type: ["string", "array"], + items: { + type: "string", + }, + }, + requireOthersToBeReadonly: { + type: "boolean", + }, + }, + additionalProperties: false, + }, + blacklist: { + type: "array", + items: { + type: ["string", "array"], + items: { + type: "string", + }, + }, + }, + ignoreInterface: { + type: "boolean", + }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + ]), +]; + +// The default options for the rule. +const defaultOptions: Options = { + ignoreClass: false, + ignoreInterface: false, + ignoreCollections: false, + allowLocalMutation: false, + allowMutableReturnType: true, + aliases: { + blacklist: "^Mutable$", + mustBeReadonly: { + pattern: "^(I?)Readonly", + requireOthersToBeMutable: false, + }, + mustBeMutable: { + pattern: "^(I?)Mutable", + requireOthersToBeReadonly: true, + }, + }, +}; + +// The possible error messages. +const errorMessages = { + aliasConfigErrorMutableReadonly: + "Configuration error - this type must be marked as both readonly and mutable.", + aliasNeedsExplicitMarking: + "Type must be explicity marked as either readonly or mutable.", + aliasShouldBeMutable: "Mutable types should not be fully readonly.", + aliasShouldBeReadonly: "Readonly types should not be mutable at all.", + arrayShouldBeReadonly: "Array should be readonly.", + propertyShouldBeReadonly: "This property should be readonly.", + tupleShouldBeReadonly: "Tuple should be readonly.", + typeShouldBeReadonly: "Type should be readonly.", +} as const; + +// The meta data for this rule. +const meta: RuleMetaData = { + type: "suggestion", + docs: { + description: "Prefer readonly types over mutable one and enforce patterns.", + category: "Best Practices", + recommended: "error", + }, + messages: errorMessages, + fixable: "code", + schema, +}; + +const mutableToImmutableTypes: ReadonlyMap = new Map< + string, + string +>([ + ["Array", "ReadonlyArray"], + ["Map", "ReadonlyMap"], + ["Set", "ReadonlySet"], +]); +const mutableTypeRegex = new RegExp( + `^${[...mutableToImmutableTypes.keys()].join("|")}$`, + "u" +); + +const enum RequiredReadonlyness { + READONLY, + MUTABLE, + EITHER, +} + +const enum TypeReadonlynessDetails { + NONE, + ERROR_MUTABLE_READONLY, + NEEDS_EXPLICIT_MARKING, + IGNORE, + MUTABLE_OK, + MUTABLE_NOT_OK, + READONLY_OK, + READONLY_NOT_OK, +} + +const cachedDetails = new WeakMap< + TSESTree.TSInterfaceDeclaration | TSESTree.TSTypeAliasDeclaration, + TypeReadonlynessDetails +>(); + +/** + * Get the details for the given type alias. + */ +function getTypeAliasDeclarationDetails( + node: TSESTree.Node, + context: RuleContext, + options: Options +): TypeReadonlynessDetails { + const typeDeclaration = getTypeDeclaration(node); + if (typeDeclaration === null) { + return TypeReadonlynessDetails.NONE; + } + + const indexSignature = getParentIndexSignature(node); + if (indexSignature !== null && getTypeDeclaration(indexSignature) !== null) { + return TypeReadonlynessDetails.IGNORE; + } + + if (options.ignoreInterface && isTSInterfaceDeclaration(typeDeclaration)) { + return TypeReadonlynessDetails.IGNORE; + } + + const cached = cachedDetails.get(typeDeclaration); + if (cached !== undefined) { + return cached; + } + + const result = getTypeAliasDeclarationDetailsInternal( + typeDeclaration, + context, + options + ); + cachedDetails.set(typeDeclaration, result); + return result; +} + +/** + * Get the details for the given type alias. + */ +function getTypeAliasDeclarationDetailsInternal( + node: TSESTree.TSInterfaceDeclaration | TSESTree.TSTypeAliasDeclaration, + context: RuleContext, + options: Options +): TypeReadonlynessDetails { + const blacklistPatterns = ( + Array.isArray(options.aliases.blacklist) + ? options.aliases.blacklist + : [options.aliases.blacklist] + ).map((pattern) => new RegExp(pattern, "u")); + + const blacklisted = blacklistPatterns.some((pattern) => + pattern.test(node.id.name) + ); + + if (blacklisted) { + return TypeReadonlynessDetails.IGNORE; + } + + const mustBeReadonlyPatterns = ( + Array.isArray(options.aliases.mustBeReadonly.pattern) + ? options.aliases.mustBeReadonly.pattern + : [options.aliases.mustBeReadonly.pattern] + ).map((pattern) => new RegExp(pattern, "u")); + + const mustBeMutablePatterns = ( + Array.isArray(options.aliases.mustBeMutable.pattern) + ? options.aliases.mustBeMutable.pattern + : [options.aliases.mustBeMutable.pattern] + ).map((pattern) => new RegExp(pattern, "u")); + + const patternStatesReadonly = mustBeReadonlyPatterns.some((pattern) => + pattern.test(node.id.name) + ); + const patternStatesMutable = mustBeMutablePatterns.some((pattern) => + pattern.test(node.id.name) + ); + + if (patternStatesReadonly && patternStatesMutable) { + return TypeReadonlynessDetails.ERROR_MUTABLE_READONLY; + } + + if ( + !patternStatesReadonly && + !patternStatesMutable && + options.aliases.mustBeReadonly.requireOthersToBeMutable && + options.aliases.mustBeMutable.requireOthersToBeReadonly + ) { + return TypeReadonlynessDetails.NEEDS_EXPLICIT_MARKING; + } + + const requiredReadonlyness = + patternStatesReadonly || + (!patternStatesMutable && + options.aliases.mustBeMutable.requireOthersToBeReadonly) + ? RequiredReadonlyness.READONLY + : patternStatesMutable || + (!patternStatesReadonly && + options.aliases.mustBeReadonly.requireOthersToBeMutable) + ? RequiredReadonlyness.MUTABLE + : RequiredReadonlyness.EITHER; + + if (requiredReadonlyness === RequiredReadonlyness.EITHER) { + return TypeReadonlynessDetails.IGNORE; + } + + const readonly = isReadonly( + isTSTypeAliasDeclaration(node) ? node.typeAnnotation : node.body, + context + ); + + if (requiredReadonlyness === RequiredReadonlyness.MUTABLE) { + return readonly + ? TypeReadonlynessDetails.MUTABLE_NOT_OK + : TypeReadonlynessDetails.MUTABLE_OK; + } + + return readonly + ? TypeReadonlynessDetails.READONLY_OK + : TypeReadonlynessDetails.READONLY_NOT_OK; +} + +/** + * Check if the given Interface or Type Alias violates this rule. + */ +function checkTypeDeclaration( + node: TSESTree.TSInterfaceDeclaration | TSESTree.TSTypeAliasDeclaration, + context: RuleContext, + options: Options +): RuleResult { + const details = getTypeAliasDeclarationDetails(node, context, options); + + switch (details) { + case TypeReadonlynessDetails.NEEDS_EXPLICIT_MARKING: { + return { + context, + descriptors: [ + { + node: node.id, + messageId: "aliasNeedsExplicitMarking", + }, + ], + }; + } + case TypeReadonlynessDetails.ERROR_MUTABLE_READONLY: { + return { + context, + descriptors: [ + { + node: node.id, + messageId: "aliasConfigErrorMutableReadonly", + }, + ], + }; + } + case TypeReadonlynessDetails.MUTABLE_NOT_OK: { + return { + context, + descriptors: [ + { + node: node.id, + messageId: "aliasShouldBeMutable", + }, + ], + }; + } + case TypeReadonlynessDetails.READONLY_NOT_OK: { + return { + context, + descriptors: [ + { + node: node.id, + messageId: "aliasShouldBeReadonly", + }, + ], + }; + } + default: { + return { + context, + descriptors: [], + }; + } + } +} + +/** + * Check if the given ArrayType or TupleType violates this rule. + */ +function checkArrayOrTupleType( + node: TSESTree.TSArrayType | TSESTree.TSTupleType, + context: RuleContext, + options: Options +): RuleResult { + if (options.ignoreCollections) { + return { + context, + descriptors: [], + }; + } + + const aliasDetails = getTypeAliasDeclarationDetails(node, context, options); + + switch (aliasDetails) { + case TypeReadonlynessDetails.NONE: + case TypeReadonlynessDetails.READONLY_NOT_OK: { + return { + context, + descriptors: + (node.parent === undefined || + !isTSTypeOperator(node.parent) || + node.parent.operator !== "readonly") && + (!options.allowMutableReturnType || !isInReturnType(node)) + ? [ + { + node, + messageId: isTSTupleType(node) + ? "tupleShouldBeReadonly" + : "arrayShouldBeReadonly", + fix: + node.parent !== undefined && isTSArrayType(node.parent) + ? (fixer) => [ + fixer.insertTextBefore(node, "(readonly "), + fixer.insertTextAfter(node, ")"), + ] + : (fixer) => fixer.insertTextBefore(node, "readonly "), + }, + ] + : [], + }; + } + default: { + return { + context, + descriptors: [], + }; + } + } +} + +/** + * Check if the given TSMappedType violates this rule. + */ +function checkMappedType( + node: TSESTree.TSMappedType, + context: RuleContext, + options: Options +): RuleResult { + const aliasDetails = getTypeAliasDeclarationDetails(node, context, options); + + switch (aliasDetails) { + case TypeReadonlynessDetails.NONE: + case TypeReadonlynessDetails.READONLY_NOT_OK: { + return { + context, + descriptors: + node.readonly === true || node.readonly === "+" + ? [] + : [ + { + node, + messageId: "propertyShouldBeReadonly", + fix: (fixer) => + fixer.insertTextBeforeRange( + [node.range[0] + 1, node.range[1]], + " readonly" + ), + }, + ], + }; + } + default: { + return { + context, + descriptors: [], + }; + } + } +} + +/** + * Check if the given TypeReference violates this rule. + */ +function checkTypeReference( + node: TSESTree.TSTypeReference, + context: RuleContext, + options: Options +): RuleResult { + if ( + !isIdentifier(node.typeName) || + (options.ignoreCollections && mutableTypeRegex.test(node.typeName.name)) + ) { + return { + context, + descriptors: [], + }; + } + + const aliasDetails = getTypeAliasDeclarationDetails(node, context, options); + + switch (aliasDetails) { + case TypeReadonlynessDetails.NONE: + case TypeReadonlynessDetails.READONLY_NOT_OK: { + const immutableType = mutableToImmutableTypes.get(node.typeName.name); + + return { + context, + descriptors: + immutableType === undefined || + immutableType.length === 0 || + (options.allowMutableReturnType && isInReturnType(node)) + ? [] + : [ + { + node, + messageId: "typeShouldBeReadonly", + fix: (fixer) => + fixer.replaceText(node.typeName, immutableType), + }, + ], + }; + } + default: { + return { + context, + descriptors: [], + }; + } + } +} + +/** + * Check if the given property/signature node violates this rule. + */ +function checkProperty( + node: + | TSESTree.ClassProperty + | TSESTree.TSIndexSignature + | TSESTree.TSParameterProperty + | TSESTree.TSPropertySignature, + context: RuleContext, + options: Options +): RuleResult { + const aliasDetails = getTypeAliasDeclarationDetails(node, context, options); + + switch (aliasDetails) { + case TypeReadonlynessDetails.NONE: + case TypeReadonlynessDetails.READONLY_NOT_OK: { + return { + context, + descriptors: + node.readonly !== true && + (!options.allowMutableReturnType || !isInReturnType(node)) + ? [ + { + node, + messageId: "propertyShouldBeReadonly", + fix: + isTSIndexSignature(node) || isTSPropertySignature(node) + ? (fixer) => fixer.insertTextBefore(node, "readonly ") + : isTSParameterProperty(node) + ? (fixer) => + fixer.insertTextBefore(node.parameter, "readonly ") + : (fixer) => + fixer.insertTextBefore(node.key, "readonly "), + }, + ] + : [], + }; + } + default: { + return { + context, + descriptors: [], + }; + } + } +} + +// Create the rule. +export const rule = createRule( + name, + meta, + defaultOptions, + { + ClassProperty: checkProperty, + TSArrayType: checkArrayOrTupleType, + TSIndexSignature: checkProperty, + TSInterfaceDeclaration: checkTypeDeclaration, + TSMappedType: checkMappedType, + TSParameterProperty: checkProperty, + TSPropertySignature: checkProperty, + TSTupleType: checkArrayOrTupleType, + TSTypeAliasDeclaration: checkTypeDeclaration, + TSTypeReference: checkTypeReference, + } +); diff --git a/src/rules/prefer-readonly-type.ts b/src/rules/prefer-readonly-type.ts index 82fb28086..7c1792a86 100644 --- a/src/rules/prefer-readonly-type.ts +++ b/src/rules/prefer-readonly-type.ts @@ -89,6 +89,11 @@ const errorMessages = { // The meta data for this rule. const meta: RuleMetaData = { + deprecated: true, + replacedBy: [ + "prefer-readonly-type-declaration", + "@typescript-eslint/prefer-readonly-parameter-types", + ], type: "suggestion", docs: { description: "Prefer readonly array over mutable arrays.", diff --git a/src/util/rule.ts b/src/util/rule.ts index b736f1a61..c1ce3924a 100644 --- a/src/util/rule.ts +++ b/src/util/rule.ts @@ -138,6 +138,21 @@ export function getTypeOfNode>( return constrained ?? nodeType; } +export function isReadonly>( + node: TSESTree.Node, + context: Context +): boolean { + const { parserServices } = context; + + if (parserServices === undefined || parserServices.program === undefined) { + return false; + } + + const checker = parserServices.program.getTypeChecker(); + const type = getTypeOfNode(node, context); + return ESLintUtils.isTypeReadonly(checker, type!); +} + /** * Get the es tree node from the given ts node. */ diff --git a/src/util/tree.ts b/src/util/tree.ts index 88cf73dae..a4ccb26fa 100644 --- a/src/util/tree.ts +++ b/src/util/tree.ts @@ -9,7 +9,10 @@ import { isMemberExpression, isMethodDefinition, isProperty, + isTSIndexSignature, isTSInterfaceBody, + isTSInterfaceDeclaration, + isTSTypeAliasDeclaration, } from "./typeguard"; /** @@ -39,6 +42,45 @@ export function inFunctionBody(node: TSESTree.Node): boolean { ); } +/** + * Get the type alias or interface that the given node is in. + */ +export function getTypeDeclaration( + node: TSESTree.Node +): TSESTree.TSInterfaceDeclaration | TSESTree.TSTypeAliasDeclaration | null { + if (isTSTypeAliasDeclaration(node) || isTSInterfaceDeclaration(node)) { + return node; + } + + return (getAncestorOfType( + (n): n is TSESTree.Node => + n.parent !== undefined && + n.parent !== null && + ((isTSTypeAliasDeclaration(n.parent) && n.parent.typeAnnotation === n) || + (isTSInterfaceDeclaration(n.parent) && n.parent.body === n)), + node + )?.parent ?? null) as + | TSESTree.TSInterfaceDeclaration + | TSESTree.TSTypeAliasDeclaration + | null; +} + +/** + * Get the parent Index Signature that the given node is in. + */ +export function getParentIndexSignature( + node: TSESTree.Node +): TSESTree.TSIndexSignature | null { + return (getAncestorOfType( + (n): n is TSESTree.Node => + n.parent !== undefined && + n.parent !== null && + isTSIndexSignature(n.parent) && + n.parent.typeAnnotation === n, + node + )?.parent ?? null) as TSESTree.TSIndexSignature | null; +} + /** * Test if the given node is in a class. */ diff --git a/src/util/typeguard.ts b/src/util/typeguard.ts index 0b7ff9e0f..30f9d5671 100644 --- a/src/util/typeguard.ts +++ b/src/util/typeguard.ts @@ -204,12 +204,24 @@ export function isTSIndexSignature( return node.type === AST_NODE_TYPES.TSIndexSignature; } +export function isTSInterfaceDeclaration( + node: TSESTree.Node +): node is TSESTree.TSInterfaceDeclaration { + return node.type === AST_NODE_TYPES.TSInterfaceDeclaration; +} + export function isTSInterfaceBody( node: TSESTree.Node ): node is TSESTree.TSInterfaceBody { return node.type === AST_NODE_TYPES.TSInterfaceBody; } +export function isTSTypeAliasDeclaration( + node: TSESTree.Node +): node is TSESTree.TSTypeAliasDeclaration { + return node.type === AST_NODE_TYPES.TSTypeAliasDeclaration; +} + export function isTSNullKeyword( node: TSESTree.Node ): node is TSESTree.TSNullKeyword { diff --git a/tests/rules/prefer-readonly-type-declaration/index.test.ts b/tests/rules/prefer-readonly-type-declaration/index.test.ts new file mode 100644 index 000000000..2ea99503c --- /dev/null +++ b/tests/rules/prefer-readonly-type-declaration/index.test.ts @@ -0,0 +1,6 @@ +import { name, rule } from "~/rules/prefer-readonly-type-declaration"; +import { testUsing } from "~/tests/helpers/testers"; + +import tsTests from "./ts"; + +testUsing.typescript(name, rule, tsTests); diff --git a/tests/rules/prefer-readonly-type-declaration/ts/index.ts b/tests/rules/prefer-readonly-type-declaration/ts/index.ts new file mode 100644 index 000000000..40a005f71 --- /dev/null +++ b/tests/rules/prefer-readonly-type-declaration/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-type-declaration/ts/invalid.ts b/tests/rules/prefer-readonly-type-declaration/ts/invalid.ts new file mode 100644 index 000000000..e5be11791 --- /dev/null +++ b/tests/rules/prefer-readonly-type-declaration/ts/invalid.ts @@ -0,0 +1,1293 @@ +import dedent from "dedent"; + +import type { InvalidTestCase } from "~/tests/helpers/util"; + +const tests: ReadonlyArray = [ + { + code: dedent` + function foo(...numbers: number[]) { + }`, + optionsSet: [[]], + output: dedent` + function foo(...numbers: readonly number[]) { + }`, + errors: [ + { + messageId: "arrayShouldBeReadonly", + type: "TSArrayType", + line: 1, + column: 26, + }, + ], + }, + { + code: dedent` + function foo(...numbers: Array) { + }`, + optionsSet: [[]], + output: dedent` + function foo(...numbers: ReadonlyArray) { + }`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 26, + }, + ], + }, + { + code: dedent` + function foo(numbers: Set) { + }`, + optionsSet: [[]], + output: dedent` + function foo(numbers: ReadonlySet) { + }`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 23, + }, + ], + }, + { + code: dedent` + function foo(numbers: Map) { + }`, + optionsSet: [[]], + output: dedent` + function foo(numbers: ReadonlyMap) { + }`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 23, + }, + ], + }, + // Should fail on Array type in interface. + { + code: dedent` + interface Foo { + readonly bar: Array + }`, + optionsSet: [[]], + output: dedent` + interface Foo { + readonly bar: ReadonlyArray + }`, + errors: [ + { + messageId: "aliasShouldBeReadonly", + type: "Identifier", + line: 1, + column: 11, + }, + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 2, + column: 17, + }, + ], + }, + // Should fail on Array type in index interface. + // https://github.com/typescript-eslint/typescript-eslint/issues/3714 + // { + // code: dedent` + // interface Foo { + // readonly [key: string]: { + // readonly groups: Array + // } + // }`, + // optionsSet: [[]], + // output: dedent` + // interface Foo { + // readonly [key: string]: { + // readonly groups: ReadonlyArray + // } + // }`, + // errors: [ + // { + // messageId: "aliasShouldBeReadonly", + // type: "Identifier", + // line: 1, + // column: 11, + // }, + // { + // messageId: "typeShouldBeReadonly", + // type: "TSTypeReference", + // line: 3, + // column: 22, + // }, + // ], + // }, + // Should fail on Array type as function return type and in local interface. + { + code: dedent` + function foo(): Array { + interface Foo { + readonly bar: Array + } + }`, + optionsSet: [[]], + output: dedent` + function foo(): Array { + interface Foo { + readonly bar: ReadonlyArray + } + }`, + errors: [ + { + messageId: "aliasShouldBeReadonly", + type: "Identifier", + line: 2, + column: 13, + }, + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 3, + column: 19, + }, + ], + }, + // Should fail on Array type as function return type and in local interface. + { + code: dedent` + const foo = (): Array => { + interface Foo { + readonly bar: Array + } + }`, + optionsSet: [[]], + output: dedent` + const foo = (): Array => { + interface Foo { + readonly bar: ReadonlyArray + } + }`, + errors: [ + { + messageId: "aliasShouldBeReadonly", + type: "Identifier", + line: 2, + column: 13, + }, + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 3, + column: 19, + }, + ], + }, + // Should fail on shorthand syntax Array type as return type. + { + code: dedent` + function foo(): number[] { + }`, + optionsSet: [[{ allowMutableReturnType: false }]], + output: dedent` + function foo(): readonly number[] { + }`, + errors: [ + { + messageId: "arrayShouldBeReadonly", + type: "TSArrayType", + line: 1, + column: 17, + }, + ], + }, + // Should fail on shorthand syntax Array type as return type. + { + code: `const foo = (): number[] => {}`, + optionsSet: [[{ allowMutableReturnType: false }]], + output: `const foo = (): readonly number[] => {}`, + errors: [ + { + messageId: "arrayShouldBeReadonly", + type: "TSArrayType", + line: 1, + column: 17, + }, + ], + }, + // Should fail inside function. + { + code: dedent` + const foo = function (): string { + let bar: Array; + };`, + optionsSet: [[]], + output: dedent` + const foo = function (): string { + let bar: ReadonlyArray; + };`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 2, + column: 12, + }, + ], + }, + // Tuples. + { + code: dedent` + function foo(tuple: [number, string]) { + }`, + optionsSet: [[]], + output: dedent` + function foo(tuple: readonly [number, string]) { + }`, + errors: [ + { + messageId: "tupleShouldBeReadonly", + type: "TSTupleType", + line: 1, + column: 21, + }, + ], + }, + { + code: dedent` + function foo(tuple: [number, string, [number, string]]) { + }`, + optionsSet: [[]], + output: dedent` + function foo(tuple: readonly [number, string, readonly [number, string]]) { + }`, + errors: [ + { + messageId: "tupleShouldBeReadonly", + type: "TSTupleType", + line: 1, + column: 21, + }, + { + messageId: "tupleShouldBeReadonly", + type: "TSTupleType", + line: 1, + column: 38, + }, + ], + }, + { + code: dedent` + function foo(tuple: readonly [number, string, [number, string]]) { + }`, + optionsSet: [[]], + output: dedent` + function foo(tuple: readonly [number, string, readonly [number, string]]) { + }`, + errors: [ + { + messageId: "tupleShouldBeReadonly", + type: "TSTupleType", + line: 1, + column: 47, + }, + ], + }, + { + code: dedent` + function foo(tuple: [number, string, readonly [number, string]]) { + }`, + optionsSet: [[]], + output: dedent` + function foo(tuple: readonly [number, string, readonly [number, string]]) { + }`, + errors: [ + { + messageId: "tupleShouldBeReadonly", + type: "TSTupleType", + line: 1, + column: 21, + }, + ], + }, + // Should fail on Array as type literal member as function parameter. + { + code: dedent` + function foo( + param1: { + readonly bar: Array, + readonly baz: ReadonlyArray + } + ): { + readonly bar: Array, + readonly baz: ReadonlyArray + } { + let foo: { + readonly bar: Array, + readonly baz: ReadonlyArray + } = { + bar: ["hello"], + baz: ["world"] + }; + return foo; + }`, + optionsSet: [[]], + output: dedent` + function foo( + param1: { + readonly bar: ReadonlyArray, + readonly baz: ReadonlyArray + } + ): { + readonly bar: Array, + readonly baz: ReadonlyArray + } { + let foo: { + readonly bar: ReadonlyArray, + readonly baz: ReadonlyArray + } = { + bar: ["hello"], + baz: ["world"] + }; + return foo; + }`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 3, + column: 19, + }, + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 11, + column: 19, + }, + ], + }, + // Should fail on Array type alias. + { + code: `type Foo = Array;`, + optionsSet: [[]], + output: `type Foo = ReadonlyArray;`, + errors: [ + { + messageId: "aliasShouldBeReadonly", + type: "Identifier", + line: 1, + column: 6, + }, + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 12, + }, + ], + }, + // Should fail on Array as type member. + { + code: dedent` + function foo() { + type Foo = { + readonly bar: Array + } + }`, + optionsSet: [[]], + output: dedent` + function foo() { + type Foo = { + readonly bar: ReadonlyArray + } + }`, + errors: [ + { + messageId: "aliasShouldBeReadonly", + type: "Identifier", + line: 2, + column: 8, + }, + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 3, + column: 19, + }, + ], + }, + // Should fail on Array type alias in local type. + { + code: dedent` + function foo() { + type Foo = Array; + }`, + optionsSet: [[]], + output: dedent` + function foo() { + type Foo = ReadonlyArray; + }`, + errors: [ + { + messageId: "aliasShouldBeReadonly", + type: "Identifier", + line: 2, + column: 8, + }, + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 2, + column: 14, + }, + ], + }, + // Should fail on Array as type member in local type. + { + code: dedent` + function foo() { + type Foo = { + readonly bar: Array + } + }`, + optionsSet: [[]], + output: dedent` + function foo() { + type Foo = { + readonly bar: ReadonlyArray + } + }`, + errors: [ + { + messageId: "aliasShouldBeReadonly", + type: "Identifier", + line: 2, + column: 8, + }, + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 3, + column: 19, + }, + ], + }, + // Should fail on Array type in variable declaration. + { + code: `const foo: Array = [];`, + optionsSet: [[]], + output: `const foo: ReadonlyArray = [];`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 12, + }, + ], + }, + // Should fail on shorthand Array syntax. + { + code: `const foo: number[] = [1, 2, 3];`, + optionsSet: [[]], + output: `const foo: readonly number[] = [1, 2, 3];`, + errors: [ + { + messageId: "arrayShouldBeReadonly", + type: "TSArrayType", + line: 1, + column: 12, + }, + ], + }, + // Should fail on Array type being used as template param. + { + code: `let x: Foo>;`, + optionsSet: [[]], + output: `let x: Foo>;`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 12, + }, + ], + }, + // Should fail on nested shorthand arrays. + { + code: `let x: readonly string[][];`, + optionsSet: [[]], + output: `let x: readonly (readonly string[])[];`, + errors: [ + { + messageId: "arrayShouldBeReadonly", + type: "TSArrayType", + line: 1, + column: 17, + }, + ], + }, + // Class Property Signatures. + { + code: dedent` + class Klass { + foo: number; + private bar: number; + static baz: number; + private static qux: number; + }`, + optionsSet: [[]], + output: dedent` + class Klass { + readonly foo: number; + private readonly bar: number; + static readonly baz: number; + private static readonly qux: number; + }`, + errors: [ + { + messageId: "propertyShouldBeReadonly", + type: "ClassProperty", + line: 2, + column: 3, + }, + { + messageId: "propertyShouldBeReadonly", + type: "ClassProperty", + line: 3, + column: 3, + }, + { + messageId: "propertyShouldBeReadonly", + type: "ClassProperty", + line: 4, + column: 3, + }, + { + messageId: "propertyShouldBeReadonly", + type: "ClassProperty", + line: 5, + column: 3, + }, + ], + }, + // Class Parameter Properties. + { + code: dedent` + class Klass { + constructor ( + public publicProp: string, + protected protectedProp: string, + private privateProp: string, + ) { } + }`, + optionsSet: [[]], + output: dedent` + class Klass { + constructor ( + public readonly publicProp: string, + protected readonly protectedProp: string, + private readonly privateProp: string, + ) { } + }`, + errors: [ + { + messageId: "propertyShouldBeReadonly", + type: "TSParameterProperty", + line: 3, + column: 5, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSParameterProperty", + line: 4, + column: 5, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSParameterProperty", + line: 5, + column: 5, + }, + ], + }, + // Interface Index Signatures. + { + code: dedent` + interface Foo { + [key: string]: string + } + interface Bar { + [key: string]: { prop: string } + }`, + optionsSet: [[]], + output: dedent` + interface Foo { + readonly [key: string]: string + } + interface Bar { + readonly [key: string]: { prop: string } + }`, + errors: [ + { + messageId: "aliasShouldBeReadonly", + type: "Identifier", + line: 1, + column: 11, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSIndexSignature", + line: 2, + column: 3, + }, + { + messageId: "aliasShouldBeReadonly", + type: "Identifier", + line: 4, + column: 11, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSIndexSignature", + line: 5, + column: 3, + }, + ], + }, + // Function Index Signatures. + { + code: dedent` + function bar(param: { [source: string]: string }): void { + return undefined; + }`, + optionsSet: [[]], + output: dedent` + function bar(param: { readonly [source: string]: string }): void { + return undefined; + }`, + errors: [ + { + messageId: "propertyShouldBeReadonly", + type: "TSIndexSignature", + line: 1, + column: 23, + }, + ], + }, + // Type literal with indexer without readonly modifier should produce failures. + { + code: `let foo: { [key: string]: number };`, + optionsSet: [[]], + output: `let foo: { readonly [key: string]: number };`, + errors: [ + { + messageId: "propertyShouldBeReadonly", + type: "TSIndexSignature", + line: 1, + column: 12, + }, + ], + }, + // Type literal in property template parameter without readonly should produce failures. + { + code: dedent` + type foo = ReadonlyArray<{ + type: string, + code: string, + }>;`, + optionsSet: [[]], + output: dedent` + type foo = ReadonlyArray<{ + readonly type: string, + readonly code: string, + }>;`, + errors: [ + { + messageId: "aliasShouldBeReadonly", + type: "Identifier", + line: 1, + column: 6, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSPropertySignature", + line: 2, + column: 3, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSPropertySignature", + line: 3, + column: 3, + }, + ], + }, + // Type literal without readonly on members should produce failures. + // Also verify that nested members are checked. + { + code: dedent` + let foo: { + a: number, + b: ReadonlyArray, + c: () => string, + d: { readonly [key: string]: string }, + [key: string]: string, + readonly e: { + a: number, + b: ReadonlyArray, + c: () => string, + d: { readonly [key: string]: string }, + [key: string]: string, + } + };`, + optionsSet: [[]], + output: dedent` + let foo: { + readonly a: number, + readonly b: ReadonlyArray, + readonly c: () => string, + readonly d: { readonly [key: string]: string }, + readonly [key: string]: string, + readonly e: { + readonly a: number, + readonly b: ReadonlyArray, + readonly c: () => string, + readonly d: { readonly [key: string]: string }, + readonly [key: string]: string, + } + };`, + errors: [ + { + messageId: "propertyShouldBeReadonly", + type: "TSPropertySignature", + line: 2, + column: 3, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSPropertySignature", + line: 3, + column: 3, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSPropertySignature", + line: 4, + column: 3, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSPropertySignature", + line: 5, + column: 3, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSIndexSignature", + line: 6, + column: 3, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSPropertySignature", + line: 8, + column: 5, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSPropertySignature", + line: 9, + column: 5, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSPropertySignature", + line: 10, + column: 5, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSPropertySignature", + line: 11, + column: 5, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSIndexSignature", + line: 12, + column: 5, + }, + ], + }, + { + code: dedent` + function foo(bar: { x: number }) { + };`, + optionsSet: [[{ allowLocalMutation: true }]], + output: dedent` + function foo(bar: { readonly x: number }) { + };`, + errors: [ + { + messageId: "propertyShouldBeReadonly", + type: "TSPropertySignature", + line: 1, + column: 21, + }, + ], + }, + // Mapped type without readonly. + { + code: dedent` + const func = (x: { [key in string]: number }) => {}`, + optionsSet: [[]], + output: dedent` + const func = (x: { readonly [key in string]: number }) => {}`, + errors: [ + { + messageId: "propertyShouldBeReadonly", + type: "TSMappedType", + line: 1, + column: 18, + }, + ], + }, + // Flag non class fields. + { + code: dedent` + class Klass { + foo() { + let bar: { + foo: number; + }; + } + }`, + optionsSet: [[{ ignoreClass: "fieldsOnly" }]], + output: dedent` + class Klass { + foo() { + let bar: { + readonly foo: number; + }; + } + }`, + errors: [ + { + messageId: "propertyShouldBeReadonly", + type: "TSPropertySignature", + line: 4, + column: 7, + }, + ], + }, + // Computed properties. + { + code: dedent` + const propertyName = 'myProperty'; + type Foo = { + [propertyName]: string; + };`, + optionsSet: [[]], + output: dedent` + const propertyName = 'myProperty'; + type Foo = { + readonly [propertyName]: string; + };`, + errors: [ + { + messageId: "aliasShouldBeReadonly", + type: "Identifier", + line: 2, + column: 6, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSPropertySignature", + line: 3, + column: 3, + }, + ], + }, + // Don't allow mutable return type. + { + code: dedent` + function foo(...numbers: ReadonlyArray): Array {} + function bar(...numbers: readonly number[]): number[] {}`, + optionsSet: [[{ allowMutableReturnType: false }]], + output: dedent` + function foo(...numbers: ReadonlyArray): ReadonlyArray {} + function bar(...numbers: readonly number[]): readonly number[] {}`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 50, + }, + { + messageId: "arrayShouldBeReadonly", + type: "TSArrayType", + line: 2, + column: 46, + }, + ], + }, + // Don't allow mutable return type. + { + code: dedent` + const foo = function(...numbers: ReadonlyArray): Array {} + const bar = function(...numbers: readonly number[]): number[] {}`, + optionsSet: [[{ allowMutableReturnType: false }]], + output: dedent` + const foo = function(...numbers: ReadonlyArray): ReadonlyArray {} + const bar = function(...numbers: readonly number[]): readonly number[] {}`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 58, + }, + { + messageId: "arrayShouldBeReadonly", + type: "TSArrayType", + line: 2, + column: 54, + }, + ], + }, + // Don't allow mutable return type. + { + code: dedent` + const foo = (...numbers: ReadonlyArray): Array => {} + const bar = (...numbers: readonly number[]): number[] => {}`, + optionsSet: [[{ allowMutableReturnType: false }]], + output: dedent` + const foo = (...numbers: ReadonlyArray): ReadonlyArray => {} + const bar = (...numbers: readonly number[]): readonly number[] => {}`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 50, + }, + { + messageId: "arrayShouldBeReadonly", + type: "TSArrayType", + line: 2, + column: 46, + }, + ], + }, + // Don't allow mutable return type. + { + code: dedent` + class Foo { + foo(...numbers: ReadonlyArray): Array { + } + } + class Bar { + foo(...numbers: readonly number[]): number[] { + } + }`, + optionsSet: [[{ allowMutableReturnType: false }]], + output: dedent` + class Foo { + foo(...numbers: ReadonlyArray): ReadonlyArray { + } + } + class Bar { + foo(...numbers: readonly number[]): readonly number[] { + } + }`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 2, + column: 43, + }, + { + messageId: "arrayShouldBeReadonly", + type: "TSArrayType", + line: 6, + column: 39, + }, + ], + }, + // Don't allow mutable return type with Type Arguments. + { + code: dedent` + function foo(...numbers: ReadonlyArray): Promise> {} + function foo(...numbers: ReadonlyArray): Promise {}`, + optionsSet: [[{ allowMutableReturnType: false }]], + output: dedent` + function foo(...numbers: ReadonlyArray): Promise> {} + function foo(...numbers: ReadonlyArray): Promise {}`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 58, + }, + { + messageId: "arrayShouldBeReadonly", + type: "TSArrayType", + line: 2, + column: 58, + }, + ], + }, + // Don't allow mutable return type with deep Type Arguments. + { + code: dedent` + type Foo = { readonly x: T; }; + function foo(...numbers: ReadonlyArray): Promise>> {} + function foo(...numbers: ReadonlyArray): Promise> {}`, + optionsSet: [[{ allowMutableReturnType: false }]], + output: dedent` + type Foo = { readonly x: T; }; + function foo(...numbers: ReadonlyArray): Promise>> {} + function foo(...numbers: ReadonlyArray): Promise> {}`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 2, + column: 62, + }, + { + messageId: "arrayShouldBeReadonly", + type: "TSArrayType", + line: 3, + column: 62, + }, + ], + }, + // Don't allow mutable return type with Type Arguments in a tuple. + { + code: dedent` + function foo(...numbers: ReadonlyArray): readonly [number, Array, number] {} + function foo(...numbers: ReadonlyArray): readonly [number, number[], number] {}`, + optionsSet: [[{ allowMutableReturnType: false }]], + output: dedent` + function foo(...numbers: ReadonlyArray): readonly [number, ReadonlyArray, number] {} + function foo(...numbers: ReadonlyArray): readonly [number, readonly number[], number] {}`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 68, + }, + { + messageId: "arrayShouldBeReadonly", + type: "TSArrayType", + line: 2, + column: 68, + }, + ], + }, + // Don't allow mutable return type with Type Arguments Union. + { + code: dedent` + function foo(...numbers: ReadonlyArray): { readonly a: Array } | { readonly b: string[] } {}`, + optionsSet: [[{ allowMutableReturnType: false }]], + output: dedent` + function foo(...numbers: ReadonlyArray): { readonly a: ReadonlyArray } | { readonly b: readonly string[] } {}`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 64, + }, + { + messageId: "arrayShouldBeReadonly", + type: "TSArrayType", + line: 1, + column: 96, + }, + ], + }, + // Don't allow mutable return type with Type Arguments Intersection. + { + code: dedent` + function foo(...numbers: ReadonlyArray): { readonly a: Array } & { readonly b: string[] } {}`, + optionsSet: [[{ allowMutableReturnType: false }]], + output: dedent` + function foo(...numbers: ReadonlyArray): { readonly a: ReadonlyArray } & { readonly b: readonly string[] } {}`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 64, + }, + { + messageId: "arrayShouldBeReadonly", + type: "TSArrayType", + line: 1, + column: 96, + }, + ], + }, + // Don't allow mutable return type with Type Arguments Conditional. + { + code: dedent` + function foo(x: T): T extends Array ? string : number[] {}`, + optionsSet: [[{ allowMutableReturnType: false }]], + output: dedent` + function foo(x: T): T extends ReadonlyArray ? string : readonly number[] {}`, + errors: [ + { + messageId: "typeShouldBeReadonly", + type: "TSTypeReference", + line: 1, + column: 34, + }, + { + messageId: "arrayShouldBeReadonly", + type: "TSArrayType", + line: 1, + column: 59, + }, + ], + }, + // Readonly types should not be mutable. + { + code: dedent` + type MyType = { + a: string; + };`, + optionsSet: [[]], + output: dedent` + type MyType = { + readonly a: string; + };`, + errors: [ + { + messageId: "aliasShouldBeReadonly", + type: "Identifier", + line: 1, + column: 6, + }, + { + messageId: "propertyShouldBeReadonly", + type: "TSPropertySignature", + line: 2, + column: 3, + }, + ], + }, + // Mutable types should not be readonly. + { + code: dedent` + type MyType = { + readonly a: string; + };`, + optionsSet: [ + [ + { + aliases: { + mustBeReadonly: { + requireOthersToBeMutable: true, + }, + mustBeMutable: { + requireOthersToBeReadonly: false, + }, + }, + }, + ], + ], + errors: [ + { + messageId: "aliasShouldBeMutable", + type: "Identifier", + line: 1, + column: 6, + }, + ], + }, + // Mutable types should not be readonly. + { + code: dedent` + type MutableMyType = { + readonly a: string; + };`, + optionsSet: [[]], + errors: [ + { + messageId: "aliasShouldBeMutable", + type: "Identifier", + line: 1, + column: 6, + }, + ], + }, + // Needs Explicit Marking. + { + code: dedent` + type MyType = {};`, + optionsSet: [ + [ + { + aliases: { + mustBeReadonly: { + requireOthersToBeMutable: true, + }, + mustBeMutable: { + requireOthersToBeReadonly: true, + }, + }, + }, + ], + ], + errors: [ + { + messageId: "aliasNeedsExplicitMarking", + type: "Identifier", + line: 1, + column: 6, + }, + ], + }, + // Both Mutable and Readonly error. + { + code: dedent` + type MyType = {};`, + optionsSet: [ + [ + { + aliases: { + mustBeReadonly: { + pattern: ".*", + }, + mustBeMutable: { + pattern: ".*", + }, + }, + }, + ], + ], + errors: [ + { + messageId: "aliasConfigErrorMutableReadonly", + type: "Identifier", + line: 1, + column: 6, + }, + ], + }, +]; + +export default tests; diff --git a/tests/rules/prefer-readonly-type-declaration/ts/valid.ts b/tests/rules/prefer-readonly-type-declaration/ts/valid.ts new file mode 100644 index 000000000..814ebea47 --- /dev/null +++ b/tests/rules/prefer-readonly-type-declaration/ts/valid.ts @@ -0,0 +1,459 @@ +import dedent from "dedent"; + +import type { ValidTestCase } from "~/tests/helpers/util"; + +const tests: ReadonlyArray = [ + // Should not fail on explicit ReadonlyArray parameter. + { + code: dedent` + function foo(...numbers: ReadonlyArray) { + }`, + optionsSet: [[]], + }, + { + code: dedent` + function foo(...numbers: readonly number[]) { + }`, + optionsSet: [[]], + }, + // Should not fail on explicit ReadonlyArray return type. + { + code: dedent` + function foo(): ReadonlyArray { + return [1, 2, 3]; + }`, + optionsSet: [[]], + }, + { + code: dedent` + const foo = (): ReadonlyArray => { + return [1, 2, 3]; + }`, + optionsSet: [[]], + }, + // ReadonlyArray Tuple. + { + code: dedent` + function foo(tuple: readonly [number, string, readonly [number, string]]) { + }`, + optionsSet: [[]], + }, + // Should not fail on ReadonlyArray type alias. + { + code: `type Foo = ReadonlyArray;`, + optionsSet: [[]], + }, + // Should not fail on ReadonlyArray type alias in local type. + { + code: dedent` + function foo() { + type Foo = ReadonlyArray; + }`, + optionsSet: [[]], + }, + // Should not fail on ReadonlyArray in variable declaration. + { + code: `const foo: ReadonlyArray = [];`, + optionsSet: [[]], + }, + // Allow mutable return type. + { + code: dedent` + function foo(...numbers: ReadonlyArray): Array {} + function bar(...numbers: readonly number[]): number[] {}`, + optionsSet: [], + }, + // Allow mutable return type. + { + code: dedent` + const foo = function(...numbers: ReadonlyArray): Array {} + const bar = function(...numbers: readonly number[]): number[] {}`, + optionsSet: [], + }, + // Allow mutable return type. + { + code: dedent` + const foo = (...numbers: ReadonlyArray): Array => {} + const bar = (...numbers: readonly number[]): number[] => {}`, + optionsSet: [], + }, + // Allow mutable return type. + { + code: dedent` + class Foo { + foo(...numbers: ReadonlyArray): Array { + } + } + class Bar { + foo(...numbers: readonly number[]): number[] { + } + }`, + optionsSet: [], + }, + // Allow mutable return type with Type Arguments. + { + code: dedent` + function foo(...numbers: ReadonlyArray): Promise> {} + function foo(...numbers: ReadonlyArray): Promise {}`, + optionsSet: [], + }, + // Allow mutable return type with deep Type Arguments. + { + code: dedent` + type Foo = { readonly x: T; }; + function foo(...numbers: ReadonlyArray): Promise>> {} + function foo(...numbers: ReadonlyArray): Promise> {}`, + optionsSet: [], + }, + // Allow mutable return type with Type Arguments in a tuple. + { + code: dedent` + function foo(...numbers: ReadonlyArray): readonly [number, Array, number] {} + function foo(...numbers: ReadonlyArray): readonly [number, number[], number] {}`, + optionsSet: [], + }, + // Allow mutable return type with Type Arguments Union. + { + code: dedent` + function foo(...numbers: ReadonlyArray): { readonly a: Array } | { readonly b: string[] } {}`, + optionsSet: [], + }, + // Allow mutable return type with Type Arguments Intersection. + { + code: dedent` + function foo(...numbers: ReadonlyArray): { readonly a: Array } & { readonly b: string[] } {}`, + optionsSet: [], + }, + // Allow mutable return type with Type Arguments Conditional. + { + code: dedent` + function foo(x: T): T extends Array ? string : number[] {}`, + optionsSet: [], + }, + // Allow inline mutable return type. + { + code: dedent` + function foo(bar: string): { baz: number } { + return 1 as any; + }`, + optionsSet: [[{ allowMutableReturnType: true }]], + }, + // Should not fail on implicit Array. + { + code: dedent` + const foo = [1, 2, 3] + function bar(param = [1, 2, 3]) {}`, + optionsSet: [[]], + }, + // Interface with readonly modifiers should not produce failures. + { + code: dedent` + interface Foo { + readonly a: number, + readonly b: ReadonlyArray, + readonly c: () => string, + readonly d: { readonly [key: string]: string }, + readonly [key: string]: string, + }`, + optionsSet: [[]], + }, + // PropertySignature and IndexSignature members without readonly modifier + // should produce failures. Also verify that nested members are checked. + { + code: dedent` + interface Foo { + readonly a: number, + readonly b: ReadonlyArray, + readonly c: () => string, + readonly d: { readonly [key: string]: string }, + readonly [key: string]: string, + readonly e: { + readonly a: number, + readonly b: ReadonlyArray, + readonly c: () => string, + readonly d: { readonly [key: string]: string }, + readonly [key: string]: string, + } + }`, + optionsSet: [[]], + }, + // Class with parameter properties. + { + code: dedent` + class Klass { + constructor ( + nonParameterProp: string, + readonly readonlyProp: string, + public readonly publicReadonlyProp: string, + protected readonly protectedReadonlyProp: string, + private readonly privateReadonlyProp: string, + ) { } + }`, + optionsSet: [[]], + }, + // CallSignature and MethodSignature cannot have readonly modifiers and should + // not produce failures. + // Waiting on https://github.com/typescript-eslint/typescript-eslint/issues/1758 + // { + // code: dedent` + // interface Foo { + // (): void + // foo(): void + // }`, + // optionsSet: [ + // [ + // { + // treatMethodsAsReadonly: true, + // }, + // ], + // ], + // }, + // The literal with indexer with readonly modifier should not produce failures. + { + code: `let foo: { readonly [key: string]: number };`, + optionsSet: [[]], + }, + // Type literal in array template parameter with readonly should not produce failures. + { + code: `type foo = ReadonlyArray<{ readonly type: string, readonly code: string }>;`, + optionsSet: [[]], + }, + // Type literal with readonly on members should not produce failures. + { + code: dedent` + let foo: { + readonly a: number, + readonly b: ReadonlyArray, + readonly c: () => string, + readonly d: { readonly [key: string]: string } + readonly [key: string]: string + };`, + optionsSet: [[]], + }, + // Mapped types with readonly on members should not produce failures. + { + code: dedent` + const func = (x: { readonly [key in string]: number }) => {}`, + optionsSet: [[]], + }, + // Ignore Classes. + { + code: dedent` + class Klass { + foo: number; + private bar: number; + static baz: number; + private static qux: number; + }`, + optionsSet: [[{ ignoreClass: true }]], + }, + // Ignore Interfaces. + { + code: dedent` + interface Foo { + foo: number, + bar: ReadonlyArray, + baz: () => string, + qux: { [key: string]: string } + }`, + optionsSet: [[{ ignoreInterface: true }]], + }, + // Allow Local. + { + code: dedent` + function foo() { + let foo: { + a: number, + b: ReadonlyArray, + c: () => string, + d: { [key: string]: string }, + [key: string]: string, + readonly d: { + a: number, + b: ReadonlyArray, + c: () => string, + d: { [key: string]: string }, + [key: string]: string, + } + } + };`, + optionsSet: [[{ allowLocalMutation: true }]], + }, + // Ignore Prefix. + { + code: dedent` + let mutableFoo: string[] = [];`, + optionsSet: [[{ ignorePattern: "^mutable" }]], + }, + { + code: dedent` + let foo: { + mutableA: number, + mutableB: ReadonlyArray, + mutableC: () => string, + mutableD: { readonly [key: string]: string }, + mutableE: { + mutableA: number, + mutableB: ReadonlyArray, + mutableC: () => string, + mutableD: { readonly [key: string]: string }, + } + };`, + optionsSet: [[{ ignorePattern: "^mutable" }]], + }, + { + code: dedent` + class Klass { + mutableA: string; + private mutableB: string; + }`, + optionsSet: [[{ ignorePattern: "^mutable" }]], + }, + // Ignore Suffix. + { + code: dedent` + let fooMutable: string[] = [];`, + optionsSet: [[{ ignorePattern: "Mutable$" }]], + }, + { + code: dedent` + let foo: { + aMutable: number, + bMutable: ReadonlyArray, + cMutable: () => string, + dMutable: { readonly [key: string]: string }, + eMutable: { + aMutable: number, + bMutable: ReadonlyArray, + cMutable: () => string, + dMutable: { readonly [key: string]: string }, + } + };`, + optionsSet: [[{ ignorePattern: "Mutable$" }]], + }, + { + code: dedent` + class Klass { + AMutable: string; + private BMutable: string; + }`, + optionsSet: [[{ ignorePattern: "Mutable$" }]], + }, + // Allow mutable TSIndexSignature. + { + code: dedent` + const mutableResult: { + [key: string]: string + } = {};`, + optionsSet: [[{ ignorePattern: "^mutable" }]], + }, + // Ignore Mutable Collections (Array, Tuple, Set, Map) + { + code: dedent`const Foo: number[] = [];`, + optionsSet: [[{ ignoreCollections: true }]], + }, + { + code: dedent`const Foo: [string, string] = ['foo', 'bar'];`, + optionsSet: [[{ ignoreCollections: true }]], + }, + { + code: dedent`const Foo: Set = new Set();`, + optionsSet: [[{ ignoreCollections: true }]], + }, + { + code: dedent`const Foo: Map = new Map();`, + optionsSet: [[{ ignoreCollections: true }]], + }, + // Readonly types should be readonly. + { + code: dedent` + type MyType = { + readonly a: string; + };`, + optionsSet: [[]], + }, + { + code: dedent` + type ReadonlyMyType = { + readonly a: string; + };`, + optionsSet: [ + [ + { + aliases: { + mustBeReadonly: { + requireOthersToBeMutable: true, + }, + mustBeMutable: { + requireOthersToBeReadonly: false, + }, + }, + }, + ], + ], + }, + // Readonly types should be readonly and mutable types mutable. + { + code: dedent` + type MutableMyType = { + a: string; + }; + type MyType = Readonly;`, + optionsSet: [[]], + }, + { + code: dedent` + type MyType = { + a: string; + }; + type ReadonlyMyType = Readonly;`, + optionsSet: [ + [ + { + aliases: { + mustBeReadonly: { + requireOthersToBeMutable: true, + }, + mustBeMutable: { + requireOthersToBeReadonly: false, + }, + }, + }, + ], + ], + }, + // Readonly types should be readonly and mutable types mutable. + { + code: dedent` + type Mutable = { -readonly[P in keyof T]: T[P] }; + type MyType = { + readonly a: string; + }; + type MutableMyType = Mutable;`, + optionsSet: [[]], + }, + { + code: dedent` + type Mutable = { -readonly[P in keyof T]: T[P] }; + type ReadonlyMyType = { + readonly a: string; + }; + type MyType = Mutable;`, + optionsSet: [ + [ + { + aliases: { + mustBeReadonly: { + requireOthersToBeMutable: true, + }, + mustBeMutable: { + requireOthersToBeReadonly: false, + }, + }, + }, + ], + ], + }, +]; + +export default tests;