diff --git a/src/configs/all.ts b/src/configs/all.ts index 0db73d9d0..e4f73acaa 100644 --- a/src/configs/all.ts +++ b/src/configs/all.ts @@ -22,6 +22,7 @@ const config: Linter.Config = { "functional/no-method-signature": "error", "functional/no-mixed-type": "error", "functional/prefer-readonly-type": "error", + "functional/prefer-readonly-type-alias": "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..8509943a4 100644 --- a/src/configs/no-mutations.ts +++ b/src/configs/no-mutations.ts @@ -11,6 +11,7 @@ const config: Linter.Config = { rules: { "functional/no-method-signature": "warn", "functional/prefer-readonly-type": "error", + "functional/prefer-readonly-type-alias": "error", }, }, ], diff --git a/src/rules/index.ts b/src/rules/index.ts index 06eb7baef..9ed1cb619 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 preferReadonlyTypeAlias from "./prefer-readonly-type-alias"; 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, + [preferReadonlyTypeAlias.name]: preferReadonlyTypeAlias.rule, [preferTacit.name]: preferTacit.rule, }; diff --git a/src/rules/prefer-readonly-type-alias.ts b/src/rules/prefer-readonly-type-alias.ts new file mode 100644 index 000000000..bcfef3cba --- /dev/null +++ b/src/rules/prefer-readonly-type-alias.ts @@ -0,0 +1,225 @@ +import type { TSESTree } from "@typescript-eslint/experimental-utils"; +import type { JSONSchema4 } from "json-schema"; + +import { isReadonly, RuleContext, RuleMetaData, RuleResult } from "~/util/rule"; +import { createRule } from "~/util/rule"; + +// The name of this rule. +export const name = "prefer-readonly-type-alias" as const; + +const enum RequiredReadonlyness { + READONLY, + MUTABLE, + EITHER, +} + +// The options this rule can take. +type Options = { + readonly mustBeReadonly: { + readonly pattern: ReadonlyArray | string; + readonly requireOthersToBeMutable: boolean; + }; + readonly mustBeMutable: { + readonly pattern: ReadonlyArray | string; + readonly requireOthersToBeReadonly: boolean; + }; +}; + +// The schema for the rule options. +const schema: JSONSchema4 = [ + { + 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, + }, + }, + additionalProperties: false, + }, +]; + +// The default options for the rule. +const defaultOptions: Options = { + mustBeReadonly: { + pattern: "^Readonly", + requireOthersToBeMutable: false, + }, + mustBeMutable: { + pattern: "^Mutable", + requireOthersToBeReadonly: true, + }, +}; + +// The possible error messages. +const errorMessages = { + mutable: "Mutable types should not be fully readonly.", + readonly: "Readonly types should not be mutable at all.", + mutableReadonly: + "Configuration error - this type must be marked as both readonly and mutable.", + needExplicitMarking: + "Type must be explicity marked as either readonly or mutable.", +} as const; + +// The meta data for this rule. +const meta: RuleMetaData = { + type: "suggestion", + docs: { + description: "Prefer readonly type alias over mutable one.", + category: "Best Practices", + recommended: "error", + }, + messages: errorMessages, + fixable: "code", + schema, +}; + +/** + * Check if the given TypeReference violates this rule. + */ +function checkTypeAliasDeclaration( + node: TSESTree.TSTypeAliasDeclaration, + context: RuleContext, + options: Options +): RuleResult { + const mustBeReadonlyPatterns = ( + Array.isArray(options.mustBeReadonly.pattern) + ? options.mustBeReadonly.pattern + : [options.mustBeReadonly.pattern] + ).map((pattern) => new RegExp(pattern, "u")); + + const mustBeMutablePatterns = ( + Array.isArray(options.mustBeMutable.pattern) + ? options.mustBeMutable.pattern + : [options.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 { + context, + descriptors: [ + { + node: node.id, + messageId: "mutableReadonly", + }, + ], + }; + } + + if ( + !patternStatesReadonly && + !patternStatesMutable && + options.mustBeReadonly.requireOthersToBeMutable && + options.mustBeMutable.requireOthersToBeReadonly + ) { + return { + context, + descriptors: [ + { + node: node.id, + messageId: "needExplicitMarking", + }, + ], + }; + } + + const requiredReadonlyness = + patternStatesReadonly || + (!patternStatesMutable && options.mustBeMutable.requireOthersToBeReadonly) + ? RequiredReadonlyness.READONLY + : patternStatesMutable || + (!patternStatesReadonly && + options.mustBeReadonly.requireOthersToBeMutable) + ? RequiredReadonlyness.MUTABLE + : RequiredReadonlyness.EITHER; + + return checkRequiredReadonlyness( + node, + context, + options, + requiredReadonlyness + ); +} + +function checkRequiredReadonlyness( + node: TSESTree.TSTypeAliasDeclaration, + context: RuleContext, + options: Options, + requiredReadonlyness: RequiredReadonlyness +): RuleResult { + if (requiredReadonlyness !== RequiredReadonlyness.EITHER) { + const readonly = isReadonly(node.typeAnnotation, context); + + if (readonly && requiredReadonlyness === RequiredReadonlyness.MUTABLE) { + return { + context, + descriptors: [ + { + node: node.id, + messageId: "readonly", + }, + ], + }; + } + + if (!readonly && requiredReadonlyness === RequiredReadonlyness.READONLY) { + return { + context, + descriptors: [ + { + node: node.id, + messageId: "mutable", + }, + ], + }; + } + } + + return { + context, + descriptors: [], + }; +} + +// Create the rule. +export const rule = createRule( + name, + meta, + defaultOptions, + { + TSTypeAliasDeclaration: checkTypeAliasDeclaration, + } +); 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/tests/rules/prefer-readonly-type-alias/index.test.ts b/tests/rules/prefer-readonly-type-alias/index.test.ts new file mode 100644 index 000000000..31f16a60c --- /dev/null +++ b/tests/rules/prefer-readonly-type-alias/index.test.ts @@ -0,0 +1,6 @@ +import { name, rule } from "~/rules/prefer-readonly-type-alias"; +import { testUsing } from "~/tests/helpers/testers"; + +import tsTests from "./ts"; + +testUsing.typescript(name, rule, tsTests); diff --git a/tests/rules/prefer-readonly-type-alias/ts/index.ts b/tests/rules/prefer-readonly-type-alias/ts/index.ts new file mode 100644 index 000000000..40a005f71 --- /dev/null +++ b/tests/rules/prefer-readonly-type-alias/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-alias/ts/invalid.ts b/tests/rules/prefer-readonly-type-alias/ts/invalid.ts new file mode 100644 index 000000000..c2ed4b5d1 --- /dev/null +++ b/tests/rules/prefer-readonly-type-alias/ts/invalid.ts @@ -0,0 +1,48 @@ +import dedent from "dedent"; + +import type { InvalidTestCase } from "~/tests/helpers/util"; + +const tests: ReadonlyArray = [ + // Readonly types should not be mutable. + { + code: dedent` + type MyType = { + a: string; + };`, + optionsSet: [[]], + // output: dedent` + // type MyType = { + // readonly a: string; + // };`, + errors: [ + { + messageId: "mutable", + type: "Identifier", + line: 1, + column: 6, + }, + ], + }, + // Mutable types should not be readonly. + { + code: dedent` + type MutableMyType = { + readonly a: string; + };`, + optionsSet: [[]], + // output: dedent` + // type MutableMyType = { + // a: string; + // };`, + errors: [ + { + messageId: "readonly", + type: "Identifier", + line: 1, + column: 6, + }, + ], + }, +]; + +export default tests; diff --git a/tests/rules/prefer-readonly-type-alias/ts/valid.ts b/tests/rules/prefer-readonly-type-alias/ts/valid.ts new file mode 100644 index 000000000..c5958b54c --- /dev/null +++ b/tests/rules/prefer-readonly-type-alias/ts/valid.ts @@ -0,0 +1,24 @@ +import dedent from "dedent"; + +import type { ValidTestCase } from "~/tests/helpers/util"; + +const tests: ReadonlyArray = [ + // Readonly types should be readonly. + { + code: dedent` + type MyType = { + readonly a: string; + };`, + optionsSet: [[]], + }, + { + code: dedent` + type MutableMyType = { + a: string; + }; + type MyType = Readonly;`, + optionsSet: [[]], + }, +]; + +export default tests;