Repository: vuejs/eslint-plugin-vue (JavaScript)

Issue:
Rule proposal: require-emit-validator
<!--
  Before proposing new rule, please make sure it hasn't been posted already.
  You can see all open propositions here:
  https://github.com/vuejs/eslint-plugin-vue/issues?q=is%3Aopen+is%3Aissue+label%3A%22new+rule+proposition%22
-->

**Please describe what the rule should do:**
<!-- A clear and concise description of what the rule should do. -->

Enforce detailed `emits` option with types.

**What category should the rule belong to?**
<!-- (place an "X" next to just one item) -->
[ ] Enforces code style (layout)
[ ] Warns about a potential error (problem)
[x] Suggests an alternate way of doing something (suggestion)
[ ] Other (please specify:)

**Provide 2-3 code examples that this rule should warn about:**

```ts
// bad
export default defineComponent({
  emits: ['update'],
})

// good
export default defineComponent({
  emits: {
    update: (payload: Payload) => typeof payload === 'object',
  },
})
```

**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->

- Declaring types of `emits` can provide better TypeScript inference.
- This rule is similar to [require-prop-types](https://eslint.vuejs.org/rules/require-prop-types.html).




Discussion:
Thank you for suggesting this rule. I like this rule!



Patch:
diff --git a/docs/rules/require-emit-validator.md b/docs/rules/require-emit-validator.md
new file mode 100644
index 000000000..9ca6df169
--- /dev/null
+++ b/docs/rules/require-emit-validator.md
@@ -0,0 +1,61 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/require-emit-validator
+description: require type definitions in emits
+---
+# vue/require-emit-validator
+
+> require type definitions in emits
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+- :gear: This rule is included in .
+
+## :book: Rule Details
+
+This rule enforces that a `emits` statement contains type definition.
+
+Declaring `emits` with types can bring better maintenance.
+Even if using with TypeScript, this can provide better type inference when annotating parameters with types.
+
+<eslint-code-block :rules="{'vue/require-emit-validator': ['error']}">
+
+```vue
+<script>
+/* ✓ GOOD */
+Vue.component('foo', {
+  emits: {
+    // Emit with arguments
+    foo: (payload) => { /* validate payload */ },
+    // Emit without parameters
+    bar: () => true,
+  }
+})
+
+/* ✗ BAD */
+Vue.component('bar', {
+  emits: ['foo']
+})
+
+Vue.component('baz', {
+  emits: {
+    foo: null,
+  }
+})
+</script>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :books: Further Reading
+
+- [API Reference](https://v3.vuejs.org/api/options-data.html#emits)
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/require-emit-validator.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/require-emit-validator.js)
diff --git a/lib/index.js b/lib/index.js
index d38cff322..1379e284e 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -140,6 +140,7 @@ module.exports = {
     'require-component-is': require('./rules/require-component-is'),
     'require-default-prop': require('./rules/require-default-prop'),
     'require-direct-export': require('./rules/require-direct-export'),
+    'require-emit-validator': require('./rules/require-emit-validator'),
     'require-explicit-emits': require('./rules/require-explicit-emits'),
     'require-name-property': require('./rules/require-name-property'),
     'require-prop-type-constructor': require('./rules/require-prop-type-constructor'),
diff --git a/lib/rules/require-emit-validator.js b/lib/rules/require-emit-validator.js
new file mode 100644
index 000000000..c85ee42ad
--- /dev/null
+++ b/lib/rules/require-emit-validator.js
@@ -0,0 +1,90 @@
+/**
+ * @fileoverview Emit definitions should be detailed
+ * @author Pig Fang
+ */
+'use strict'
+
+const utils = require('../utils')
+
+/**
+ * @typedef {import('../utils').ComponentArrayEmit} ComponentArrayEmit
+ * @typedef {import('../utils').ComponentObjectEmit} ComponentObjectEmit
+ */
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description: 'require type definitions in emits',
+      categories: [],
+      url: 'https://eslint.vuejs.org/rules/require-emit-validator.html'
+    },
+    fixable: null,
+    messages: {
+      missing: 'Emit "{{name}}" should define at least its validator function.',
+      skipped:
+        'Emit "{{name}}" should not skip validation, or you may define a validator function with no parameters.',
+      emptyValidation: 'Replace with a validator function with no parameters.'
+    },
+    schema: []
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    // ----------------------------------------------------------------------
+    // Helpers
+    // ----------------------------------------------------------------------
+
+    /**
+     * @param {ComponentArrayEmit|ComponentObjectEmit} emit
+     */
+    function checker({ value, node, emitName }) {
+      const hasType =
+        !!value &&
+        (value.type === 'ArrowFunctionExpression' ||
+          value.type === 'FunctionExpression' ||
+          // validator may from outer scope
+          value.type === 'Identifier')
+
+      if (!hasType) {
+        const name =
+          emitName ||
+          (node.type === 'Identifier' && node.name) ||
+          'Unknown emit'
+
+        if (value && value.type === 'Literal' && value.value === null) {
+          context.report({
+            node,
+            messageId: 'skipped',
+            data: { name },
+            suggest: [
+              {
+                messageId: 'emptyValidation',
+                fix: (fixer) => fixer.replaceText(value, '() => true')
+              }
+            ]
+          })
+
+          return
+        }
+
+        context.report({
+          node,
+          messageId: 'missing',
+          data: { name }
+        })
+      }
+    }
+
+    // ----------------------------------------------------------------------
+    // Public
+    // ----------------------------------------------------------------------
+
+    return utils.executeOnVue(context, (obj) => {
+      utils.getComponentEmits(obj).forEach(checker)
+    })
+  }
+}


Test patch:
diff --git a/tests/lib/rules/require-emit-validator.js b/tests/lib/rules/require-emit-validator.js
new file mode 100644
index 000000000..6c1170e95
--- /dev/null
+++ b/tests/lib/rules/require-emit-validator.js
@@ -0,0 +1,330 @@
+/**
+ * @fileoverview Emit definitions should be detailed
+ * @author Pig Fang
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const rule = require('../../../lib/rules/require-emit-validator')
+
+const RuleTester = require('eslint').RuleTester
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester()
+ruleTester.run('require-emit-validator', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          ...foo,
+          emits: {
+            ...test(),
+            foo: (payload) => typeof payload === 'object'
+          }
+        }
+      `,
+      parserOptions: { ecmaVersion: 2018, sourceType: 'module' }
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          emits: {
+            foo: (payload) => typeof payload === 'object'
+          }
+        }
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' }
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          emits: {
+            foo(payload) {
+              return typeof payload === 'object'
+            }
+          }
+        }
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' }
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          emits: {
+            foo: () => {}
+          }
+        }
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' }
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          emits: {
+            foo() {}
+          }
+        }
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' }
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          emits
+        }
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' }
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          emits: externalEmits
+        }
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' }
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          emits: []
+        }
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' }
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          emits: {}
+        }
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' }
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default defineComponent({
+          emits: {
+            foo: (payload: string | number) => true,
+          }
+        })
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      parser: require.resolve('@typescript-eslint/parser')
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default defineComponent({
+          emits: {
+            foo(payload: string | number) {
+              return true
+            },
+          },
+        })
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      parser: require.resolve('@typescript-eslint/parser')
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        function foo () {}
+        export default {
+          emits: {
+            foo
+          }
+        }
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' }
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        import { isNumber } from './mod'
+        export default {
+          emits: {
+            foo: isNumber
+          }
+        }
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' }
+    }
+  ],
+
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          emits: ['foo', bar, \`baz\`, foo()]
+        }
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      errors: [
+        {
+          messageId: 'missing',
+          data: { name: 'foo' },
+          line: 3
+        },
+        {
+          messageId: 'missing',
+          data: { name: 'bar' },
+          line: 3
+        },
+        {
+          messageId: 'missing',
+          data: { name: 'baz' },
+          line: 3
+        },
+        {
+          messageId: 'missing',
+          data: { name: 'Unknown emit' },
+          line: 3
+        }
+      ]
+    },
+    {
+      filename: 'test.js',
+      code: `
+        new Vue({
+          emits: ['foo', bar, \`baz\`, foo()]
+        })
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      errors: [
+        {
+          messageId: 'missing',
+          data: { name: 'foo' },
+          line: 3
+        },
+        {
+          messageId: 'missing',
+          data: { name: 'bar' },
+          line: 3
+        },
+        {
+          messageId: 'missing',
+          data: { name: 'baz' },
+          line: 3
+        },
+        {
+          messageId: 'missing',
+          data: { name: 'Unknown emit' },
+          line: 3
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          emits: {
+            foo: null
+          }
+        }`,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      errors: [
+        {
+          messageId: 'skipped',
+          data: { name: 'foo' },
+          line: 4,
+          suggestions: [
+            {
+              messageId: 'emptyValidation',
+              output: `
+        export default {
+          emits: {
+            foo: () => true
+          }
+        }`
+            }
+          ]
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          emits: {
+            foo: null,
+            bar: (payload) => {}
+          }
+        }`,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      errors: [
+        {
+          messageId: 'skipped',
+          data: { name: 'foo' },
+          line: 4,
+          suggestions: [
+            {
+              messageId: 'emptyValidation',
+              output: `
+        export default {
+          emits: {
+            foo: () => true,
+            bar: (payload) => {}
+          }
+        }`
+            }
+          ]
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          emits: {
+            foo: {
+              type: String
+            }
+          }
+        }
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      errors: [
+        {
+          messageId: 'missing',
+          data: { name: 'foo' },
+          line: 4
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default defineComponent({
+          emits: {
+            foo: {} as ((payload: string) => boolean)
+          }
+        });
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      parser: require.resolve('@typescript-eslint/parser'),
+      errors: [
+        {
+          messageId: 'missing',
+          data: { name: 'foo' },
+          line: 4
+        }
+      ]
+    }
+  ]
+})