diff --git a/docs/rules/index.md b/docs/rules/index.md
index c2093a8c4..cc94b716b 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -267,6 +267,7 @@ For example:
| [vue/require-macro-variable-name](./require-macro-variable-name.md) | require a certain macro variable name | :bulb: | :hammer: |
| [vue/require-name-property](./require-name-property.md) | require a name property in Vue components | :bulb: | :hammer: |
| [vue/require-prop-comment](./require-prop-comment.md) | require props to have a comment | | :hammer: |
+| [vue/require-typed-object-prop](./require-typed-object-prop.md) | enforce adding type declarations to object props | :bulb: | :hammer: |
| [vue/require-typed-ref](./require-typed-ref.md) | require `ref` and `shallowRef` functions to be strongly typed | | :hammer: |
| [vue/script-indent](./script-indent.md) | enforce consistent indentation in `
+```
+
+
+
+## :wrench: Options
+
+Nothing.
+
+## :mute: When Not To Use It
+
+When you're not using TypeScript in the project.
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/require-typed-object-prop.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/require-typed-object-prop.js)
diff --git a/lib/index.js b/lib/index.js
index 66ab217bd..c7aa8c961 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -191,6 +191,7 @@ module.exports = {
'require-render-return': require('./rules/require-render-return'),
'require-slots-as-functions': require('./rules/require-slots-as-functions'),
'require-toggle-inside-transition': require('./rules/require-toggle-inside-transition'),
+ 'require-typed-object-prop': require('./rules/require-typed-object-prop'),
'require-typed-ref': require('./rules/require-typed-ref'),
'require-v-for-key': require('./rules/require-v-for-key'),
'require-valid-default-prop': require('./rules/require-valid-default-prop'),
diff --git a/lib/rules/require-typed-object-prop.js b/lib/rules/require-typed-object-prop.js
new file mode 100644
index 000000000..dda7b8346
--- /dev/null
+++ b/lib/rules/require-typed-object-prop.js
@@ -0,0 +1,150 @@
+/**
+ * @author Przemysław Jan Beigert
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const utils = require('../utils')
+
+/**
+ * @param {RuleContext} context
+ * @param {Identifier} identifierNode
+ */
+const checkPropIdentifierType = (context, identifierNode) => {
+ if (identifierNode.name === 'Object' || identifierNode.name === 'Array') {
+ const arrayTypeSuggestion = identifierNode.name === 'Array' ? '[]' : ''
+ context.report({
+ node: identifierNode,
+ messageId: 'expectedTypeAnnotation',
+ suggest: [
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: `any${arrayTypeSuggestion}` },
+ fix(fixer) {
+ return fixer.insertTextAfter(
+ identifierNode,
+ ` as PropType`
+ )
+ }
+ },
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: `unknown${arrayTypeSuggestion}` },
+ fix(fixer) {
+ return fixer.insertTextAfter(
+ identifierNode,
+ ` as PropType`
+ )
+ }
+ }
+ ]
+ })
+ }
+}
+
+/**
+ * @param {RuleContext} context
+ * @param {ArrayExpression} arrayNode
+ */
+const checkPropArrayType = (context, arrayNode) => {
+ for (const elementNode of arrayNode.elements) {
+ if (elementNode?.type === 'Identifier') {
+ checkPropIdentifierType(context, elementNode)
+ }
+ }
+}
+
+/**
+ * @param {RuleContext} context
+ * @param {ObjectExpression} objectNode
+ */
+const checkPropObjectType = (context, objectNode) => {
+ const typeProperty = objectNode.properties.find(
+ (prop) =>
+ prop.type === 'Property' &&
+ prop.key.type === 'Identifier' &&
+ prop.key.name === 'type'
+ )
+ if (!typeProperty || typeProperty.type !== 'Property') {
+ return
+ }
+
+ if (typeProperty.value.type === 'Identifier') {
+ // `foo: { type: String }`
+ checkPropIdentifierType(context, typeProperty.value)
+ } else if (typeProperty.value.type === 'ArrayExpression') {
+ // `foo: { type: [String, Boolean] }`
+ checkPropArrayType(context, typeProperty.value)
+ }
+}
+
+/**
+ * @param {import('../utils').ComponentProp} prop
+ * @param {RuleContext} context
+ */
+const checkProp = (prop, context) => {
+ if (prop.type !== 'object') {
+ return
+ }
+
+ switch (prop.node.value.type) {
+ case 'Identifier': {
+ // e.g. `foo: String`
+ checkPropIdentifierType(context, prop.node.value)
+ break
+ }
+ case 'ArrayExpression': {
+ // e.g. `foo: [String, Boolean]`
+ checkPropArrayType(context, prop.node.value)
+ break
+ }
+ case 'ObjectExpression': {
+ // e.g. `foo: { type: … }`
+ checkPropObjectType(context, prop.node.value)
+ return
+ }
+ }
+}
+
+//------------------------------------------------------------------------------
+// Rule Definition
+//------------------------------------------------------------------------------
+
+module.exports = {
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description: 'enforce adding type declarations to object props',
+ categories: undefined,
+ recommended: false,
+ url: 'https://eslint.vuejs.org/rules/require-typed-object-prop.html'
+ },
+ fixable: null,
+ schema: [],
+ // eslint-disable-next-line eslint-plugin/require-meta-has-suggestions -- `context.report` with suggestion is not recognized in `checkPropIdentifierType`
+ hasSuggestions: true,
+ messages: {
+ expectedTypeAnnotation: 'Expected type annotation on object prop.',
+ addTypeAnnotation: 'Add `{{ type }}` type annotation.'
+ }
+ },
+ /** @param {RuleContext} context */
+ create(context) {
+ return utils.compositingVisitors(
+ utils.defineScriptSetupVisitor(context, {
+ onDefinePropsEnter(_node, props) {
+ for (const prop of props) {
+ checkProp(prop, context)
+ }
+ }
+ }),
+ utils.executeOnVue(context, (obj) => {
+ const props = utils.getComponentPropsFromOptions(obj)
+
+ for (const prop of props) {
+ checkProp(prop, context)
+ }
+ })
+ )
+ }
+}
diff --git a/tests/lib/rules/require-typed-object-prop.js b/tests/lib/rules/require-typed-object-prop.js
new file mode 100644
index 000000000..5d93965a5
--- /dev/null
+++ b/tests/lib/rules/require-typed-object-prop.js
@@ -0,0 +1,621 @@
+/**
+ * @author Przemysław Jan Beigert
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/require-typed-object-prop')
+
+const ruleTester = new RuleTester()
+
+ruleTester.run('require-typed-object-prop', rule, {
+ valid: [
+ // empty
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+ export default {
+ props: {}
+ }
+ `
+ },
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+ export default Vue.extend({
+ props: {}
+ });
+ `
+ },
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+
+ `,
+ parser: require.resolve('vue-eslint-parser')
+ },
+ // array props
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+ export default {
+ props: ['foo']
+ }
+ `
+ },
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+ export default Vue.extend({
+ props: ['foo']
+ });
+ `
+ },
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+
+ `,
+ parser: require.resolve('vue-eslint-parser')
+ },
+ // primitive props
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+ export default {
+ props: { foo: String }
+ }
+ `
+ },
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+ export default Vue.extend({
+ props: { foo: String }
+ });
+ `
+ },
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+
+ `,
+ parser: require.resolve('vue-eslint-parser')
+ },
+ // union
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+ export default {
+ props: { foo: [Number, String, Boolean] }
+ }
+ `
+ },
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+ export default Vue.extend({
+ props: { foo: [Number, String, Boolean] }
+ });
+ `
+ },
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+
+ `,
+ parser: require.resolve('vue-eslint-parser')
+ },
+ // function
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+ export default {
+ props: { foo: someFunction() }
+ }
+ `
+ },
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+ export default Vue.extend({
+ props: { foo: someFunction() }
+ });
+ `
+ },
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+
+ `,
+ parser: require.resolve('vue-eslint-parser')
+ },
+ // typed object
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+ export default {
+ props: { foo: Object as PropType }
+ }
+ `,
+ parser: require.resolve('@typescript-eslint/parser')
+ },
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+ export default {
+ props: { foo: Array as PropType }
+ }
+ `,
+ parser: require.resolve('@typescript-eslint/parser')
+ },
+ {
+ filename: 'test.vue',
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ code: `
+ export default Vue.extend({
+ props: { foo: Object as PropType }
+ });
+ `,
+ parser: require.resolve('@typescript-eslint/parser')
+ },
+ {
+ filename: 'test.vue',
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ parser: require.resolve('@typescript-eslint/parser')
+ },
+ code: `
+
+ `,
+ parser: require.resolve('vue-eslint-parser')
+ },
+
+ {
+ filename: 'test.vue',
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ parser: require.resolve('@typescript-eslint/parser')
+ },
+ code: `
+ export default {
+ props: { foo: Object as () => User }
+ }
+ `,
+ parser: require.resolve('@typescript-eslint/parser')
+ },
+ {
+ filename: 'test.vue',
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ parser: require.resolve('@typescript-eslint/parser')
+ },
+ code: `
+ export default Vue.extend({
+ props: { foo: Object as () => User }
+ });
+ `,
+ parser: require.resolve('@typescript-eslint/parser')
+ },
+ {
+ filename: 'test.vue',
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ parser: require.resolve('@typescript-eslint/parser')
+ },
+ code: `
+
+ `,
+ parser: require.resolve('vue-eslint-parser')
+ },
+ // any
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ parser: require.resolve('@typescript-eslint/parser')
+ },
+ parser: require.resolve('vue-eslint-parser')
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ parser: require.resolve('@typescript-eslint/parser')
+ },
+ parser: require.resolve('vue-eslint-parser')
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ parser: require.resolve('@typescript-eslint/parser')
+ },
+ parser: require.resolve('vue-eslint-parser')
+ },
+ // unknown
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ parser: require.resolve('@typescript-eslint/parser')
+ },
+ parser: require.resolve('vue-eslint-parser')
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ parser: require.resolve('@typescript-eslint/parser')
+ },
+ parser: require.resolve('vue-eslint-parser')
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ parser: require.resolve('@typescript-eslint/parser')
+ },
+ parser: require.resolve('vue-eslint-parser')
+ }
+ ],
+ invalid: [
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ parser: require.resolve('vue-eslint-parser'),
+ errors: [
+ {
+ messageId: 'expectedTypeAnnotation',
+ line: 3,
+ column: 26,
+ endLine: 3,
+ endColumn: 32,
+ suggestions: [
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: 'any' },
+ output: `
+
+ `
+ },
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: 'unknown' },
+ output: `
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ parser: require.resolve('vue-eslint-parser'),
+ errors: [
+ {
+ messageId: 'expectedTypeAnnotation',
+ line: 3,
+ column: 26,
+ endLine: 3,
+ endColumn: 31,
+ suggestions: [
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: 'any[]' },
+ output: `
+
+ `
+ },
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: 'unknown[]' },
+ output: `
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+ export default {
+ props: { foo: Object }
+ }
+ `,
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ errors: [
+ {
+ messageId: 'expectedTypeAnnotation',
+ line: 3,
+ column: 23,
+ endLine: 3,
+ endColumn: 29,
+ suggestions: [
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: 'any' },
+ output: `
+ export default {
+ props: { foo: Object as PropType }
+ }
+ `
+ },
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: 'unknown' },
+ output: `
+ export default {
+ props: { foo: Object as PropType }
+ }
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+ export default Vue.extend({
+ props: { foo: Object }
+ });
+ `,
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ errors: [
+ {
+ messageId: 'expectedTypeAnnotation',
+ line: 3,
+ column: 23,
+ endLine: 3,
+ endColumn: 29,
+ suggestions: [
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: 'any' },
+ output: `
+ export default Vue.extend({
+ props: { foo: Object as PropType }
+ });
+ `
+ },
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: 'unknown' },
+ output: `
+ export default Vue.extend({
+ props: { foo: Object as PropType }
+ });
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+ export default Vue.extend({
+ props: { foo: { type: Object } }
+ });
+ `,
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ errors: [
+ {
+ messageId: 'expectedTypeAnnotation',
+ line: 3,
+ column: 31,
+ endLine: 3,
+ endColumn: 37,
+ suggestions: [
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: 'any' },
+ output: `
+ export default Vue.extend({
+ props: { foo: { type: Object as PropType } }
+ });
+ `
+ },
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: 'unknown' },
+ output: `
+ export default Vue.extend({
+ props: { foo: { type: Object as PropType } }
+ });
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+ export default {
+ props: { foo: { type: Object } }
+ }
+ `,
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ errors: [
+ {
+ messageId: 'expectedTypeAnnotation',
+ line: 3,
+ column: 31,
+ endLine: 3,
+ endColumn: 37,
+ suggestions: [
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: 'any' },
+ output: `
+ export default {
+ props: { foo: { type: Object as PropType } }
+ }
+ `
+ },
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: 'unknown' },
+ output: `
+ export default {
+ props: { foo: { type: Object as PropType } }
+ }
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+ parser: require.resolve('vue-eslint-parser'),
+ errors: [
+ {
+ messageId: 'expectedTypeAnnotation',
+ line: 3,
+ column: 34,
+ endLine: 3,
+ endColumn: 40,
+ suggestions: [
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: 'any' },
+ output: `
+
+ `
+ },
+ {
+ messageId: 'addTypeAnnotation',
+ data: { type: 'unknown' },
+ output: `
+
+ `
+ }
+ ]
+ }
+ ]
+ }
+ ]
+})