diff --git a/packages/eslint-plugin/src/rules/prefer-readonly-parameter-types.ts b/packages/eslint-plugin/src/rules/prefer-readonly-parameter-types.ts index 71e5efe2f74d..47c4dfaa023b 100644 --- a/packages/eslint-plugin/src/rules/prefer-readonly-parameter-types.ts +++ b/packages/eslint-plugin/src/rules/prefer-readonly-parameter-types.ts @@ -7,6 +7,7 @@ import type { TypeOrValueSpecifier } from '../util'; import { createRule, getParserServices, + isTypeBrandedLiteralLike, isTypeReadonly, readonlynessOptionsDefaults, readonlynessOptionsSchema, @@ -129,7 +130,7 @@ export default createRule({ treatMethodsAsReadonly: !!treatMethodsAsReadonly, }); - if (!isReadOnly) { + if (!isReadOnly && !isTypeBrandedLiteralLike(type)) { context.report({ node: actualParam, messageId: 'shouldBeReadonly', diff --git a/packages/eslint-plugin/tests/rules/prefer-readonly-parameter-types.test.ts b/packages/eslint-plugin/tests/rules/prefer-readonly-parameter-types.test.ts index 027f2f7ae49e..94381614160a 100644 --- a/packages/eslint-plugin/tests/rules/prefer-readonly-parameter-types.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-readonly-parameter-types.test.ts @@ -320,6 +320,114 @@ function foo(arg: Test) {} const willNotCrash = (foo: Readonly) => {}; `, + ` +type TaggedBigInt = bigint & { + readonly __tag: unique symbol; +}; +function custom1(arg: TaggedBigInt) {} + `, + ` +type TaggedNumber = number & { + readonly __tag: unique symbol; +}; +function custom1(arg: TaggedNumber) {} + `, + ` +type TaggedString = string & { + readonly __tag: unique symbol; +}; +function custom1(arg: TaggedString) {} + `, + ` +type TaggedString = string & { + readonly __tagA: unique symbol; + readonly __tagB: unique symbol; +}; +function custom1(arg: TaggedString) {} + `, + ` +type TaggedString = string & { + readonly __tag: unique symbol; +}; + +type OtherSpecialString = string & { + readonly ' __other_tag': unique symbol; +}; + +function custom1(arg: TaggedString | OtherSpecialString) {} + `, + ` +type TaggedTemplateLiteral = \`\${string}-\${string}\` & { + readonly __tag: unique symbol; +}; +function custom1(arg: TaggedTemplateLiteral) {} + `, + ` +type TaggedNumber = 1 & { + readonly __tag: unique symbol; +}; + +function custom1(arg: TaggedNumber) {} + `, + ` +type TaggedNumber = (1 | 2) & { + readonly __tag: unique symbol; +}; + +function custom1(arg: TaggedNumber) {} + `, + ` +type TaggedString = ('a' | 'b') & { + readonly __tag: unique symbol; +}; + +function custom1(arg: TaggedString) {} + `, + ` +type Strings = 'one' | 'two' | 'three'; + +type TaggedString = Strings & { + readonly __tag: unique symbol; +}; + +function custom1(arg: TaggedString) {} + `, + ` +type Strings = 'one' | 'two' | 'three'; + +type TaggedString = Strings & { + __tag: unique symbol; +}; + +function custom1(arg: TaggedString) {} + `, + ` +type TaggedString = string & { + __tag: unique symbol; +} & { + __tag: unique symbol; +}; +function custom1(arg: TaggedString) {} + `, + ` +type TaggedString = string & { + __tagA: unique symbol; +} & { + __tagB: unique symbol; +}; +function custom1(arg: TaggedString) {} + `, + ` +type TaggedString = string & + ({ __tag: unique symbol } | { __tag: unique symbol }); +function custom1(arg: TaggedString) {} + `, + ` +type TaggedFunction = (() => void) & { + readonly __tag: unique symbol; +}; +function custom1(arg: TaggedFunction) {} + `, { code: ` type Callback = (options: T) => void; @@ -909,6 +1017,23 @@ function foo(arg: Test) {} }, ], }, + { + code: ` +class ClassExample {} +type Test = typeof ClassExample & { + readonly property: boolean; +}; +function foo(arg: Test) {} + `, + errors: [ + { + column: 14, + endColumn: 23, + line: 6, + messageId: 'shouldBeReadonly', + }, + ], + }, { code: ` const sym = Symbol('sym'); diff --git a/packages/type-utils/src/index.ts b/packages/type-utils/src/index.ts index 8435b4e1f429..8b83a1931405 100644 --- a/packages/type-utils/src/index.ts +++ b/packages/type-utils/src/index.ts @@ -6,6 +6,7 @@ export * from './getDeclaration'; export * from './getSourceFileOfNode'; export * from './getTypeName'; export * from './isSymbolFromDefaultLibrary'; +export * from './isTypeBrandedLiteralLike'; export * from './isTypeReadonly'; export * from './isUnsafeAssignment'; export * from './predicates'; diff --git a/packages/type-utils/src/isTypeBrandedLiteralLike.ts b/packages/type-utils/src/isTypeBrandedLiteralLike.ts new file mode 100644 index 000000000000..93852c95fbc4 --- /dev/null +++ b/packages/type-utils/src/isTypeBrandedLiteralLike.ts @@ -0,0 +1,50 @@ +import * as tsutils from 'ts-api-utils'; +import * as ts from 'typescript'; + +function isLiteralOrTaggablePrimitiveLike(type: ts.Type): boolean { + return ( + type.isLiteral() || + tsutils.isTypeFlagSet( + type, + ts.TypeFlags.BigInt | + ts.TypeFlags.Number | + ts.TypeFlags.String | + ts.TypeFlags.TemplateLiteral, + ) + ); +} + +function isObjectLiteralLike(type: ts.Type): boolean { + return ( + !type.getCallSignatures().length && + !type.getConstructSignatures().length && + tsutils.isObjectType(type) + ); +} + +function isTypeBrandedLiteral(type: ts.Type): boolean { + if (!type.isIntersection()) { + return false; + } + + let hadObjectLike = false; + let hadPrimitiveLike = false; + + for (const constituent of type.types) { + if (isObjectLiteralLike(constituent)) { + hadPrimitiveLike = true; + } else if (isLiteralOrTaggablePrimitiveLike(constituent)) { + hadObjectLike = true; + } else { + return false; + } + } + + return hadPrimitiveLike && hadObjectLike; +} + +export function isTypeBrandedLiteralLike(type: ts.Type): boolean { + return type.isUnion() + ? type.types.every(isTypeBrandedLiteral) + : isTypeBrandedLiteral(type); +}