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

Issue:
New rule `vue/require-typed-ref`: disallow ref() without type parameter or argument
<!--
  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. -->

Disallow calling `ref()` function without generic type parameter or an argument when using TypeScript.


With TypeScript it is easy to prevent usage of `any` by using `no-implicit-any`. Unfortunately this rule is easily bypassed with Vue `ref()` function. Calling `ref()` function without a generic parameter or an initial value leads to ref having `Ref<any>` type.

**What category should the rule belong to?**
<!-- (place an "X" next to just one item) -->
[x] Enforces code style (layout)
[x] Warns about a potential error (problem)
[ ] Suggests an alternate way of doing something (suggestion)
[ ] Other (please specify:)

**Provide 2-3 code examples that this rule should warn about:**

Bad:
```vue
<script setup lang="ts">
const counter = ref() // Ref<any>

counter.value = 0
counter.value = '' // Would be caught if we had typed ref
</script>
```

Bad: (maybe as an option to disallow explicit any too)
```vue
<script setup lang="ts">
const counter = ref<any>() // Ref<any>
</script>
```

Good:
```vue
<script setup lang="ts">
const counter = ref(0) // Ref<number>

conter.value = '' // Will result in a typecheck error
</script>
```

Good:
```vue
<script setup lang="ts">
const counter = ref<number>()
</script>
```

Additional example by @FloEdelmann
Good:
```vue
<script setup lang="ts">
const counter: Ref<number | undefined> = ref()
</script>
```

**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->


Pull request description:
Add `vue/require-typed-ref` rule
Closes #2105

Patch:
diff --git a/docs/rules/define-macros-order.md b/docs/rules/define-macros-order.md
index c4e36b25..37074f05 100644
--- a/docs/rules/define-macros-order.md
+++ b/docs/rules/define-macros-order.md
@@ -66,7 +66,7 @@ defineEmits(/* ... */)
 
 </eslint-code-block>
 
-### `{ "order": ["defineOptions", "defineProps", "defineEmits", "defineSlots"] }` (default)
+### `{ "order": ["defineOptions", "defineProps", "defineEmits", "defineSlots"] }`
 
 <eslint-code-block fix :rules="{'vue/define-macros-order': ['error', {order: ['defineOptions', 'defineProps', 'defineEmits', 'defineSlots']}]}">
 
diff --git a/docs/rules/index.md b/docs/rules/index.md
index d6469f3e..605cd4ef 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -265,6 +265,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-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 `<script>` | :wrench: | :lipstick: |
 | [vue/sort-keys](./sort-keys.md) | enforce sort-keys in a manner that is compatible with order-in-components |  | :hammer: |
 | [vue/static-class-names-order](./static-class-names-order.md) | enforce static class names order | :wrench: | :hammer: |
diff --git a/docs/rules/require-typed-ref.md b/docs/rules/require-typed-ref.md
new file mode 100644
index 00000000..933c26cb
--- /dev/null
+++ b/docs/rules/require-typed-ref.md
@@ -0,0 +1,47 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/require-typed-ref
+description: require `ref` and `shallowRef` functions to be strongly typed
+---
+# vue/require-typed-ref
+
+> require `ref` and `shallowRef` functions to be strongly typed
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+
+## :book: Rule Details
+
+This rule disallows calling `ref()` or `shallowRef()` functions without generic type parameter or an argument when using TypeScript.
+
+With TypeScript it is easy to prevent usage of `any` by using [`noImplicitAny`](https://www.typescriptlang.org/tsconfig#noImplicitAny). Unfortunately this rule is easily bypassed with Vue `ref()` function. Calling `ref()` function without a generic parameter or an initial value leads to ref having `Ref<any>` type.
+
+<eslint-code-block :rules="{'vue/require-typed-ref': ['error']}">
+
+```vue
+<script setup lang="ts">
+import { ref, shallowRef, type Ref } from 'vue'
+
+/* ✗ BAD */
+const count = ref() // Returns Ref<any> that is not type checked
+count.value = '50' // Should be a type error, but it is not
+
+const count = shallowRef()
+
+/* ✓ GOOD */
+const count = ref<number>()
+const count = ref(0)
+const count: Ref<number | undefined> = ref()
+</script>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/require-typed-ref.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/require-typed-ref.js)
diff --git a/lib/index.js b/lib/index.js
index 6ca29d49..41c5b6b8 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -189,6 +189,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-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'),
     'return-in-computed-property': require('./rules/return-in-computed-property'),
diff --git a/lib/rules/require-typed-ref.js b/lib/rules/require-typed-ref.js
new file mode 100644
index 00000000..f8fce39d
--- /dev/null
+++ b/lib/rules/require-typed-ref.js
@@ -0,0 +1,102 @@
+/**
+ * @author Ivan Demchuk <https://github.com/Demivan>
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const { iterateDefineRefs } = require('../utils/ref-object-references')
+const utils = require('../utils')
+
+/**
+ * @param {Expression|SpreadElement} node
+ */
+function isNullOrUndefined(node) {
+  return (
+    (node.type === 'Literal' && node.value === null) ||
+    (node.type === 'Identifier' && node.name === 'undefined')
+  )
+}
+
+/**
+ * @typedef {import('../utils/ref-object-references').RefObjectReferences} RefObjectReferences
+ */
+
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description:
+        'require `ref` and `shallowRef` functions to be strongly typed',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/require-typed-ref.html'
+    },
+    fixable: null,
+    messages: {
+      noType:
+        'Specify type parameter for `{{name}}` function, otherwise created variable will not by typechecked.'
+    },
+    schema: []
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    const filename = context.getFilename()
+    if (!utils.isVueFile(filename) && !utils.isTypeScriptFile(filename)) {
+      return {}
+    }
+
+    const scriptSetup = utils.getScriptSetupElement(context)
+    if (
+      scriptSetup &&
+      !utils.hasAttribute(scriptSetup, 'lang', 'ts') &&
+      !utils.hasAttribute(scriptSetup, 'lang', 'typescript')
+    ) {
+      return {}
+    }
+
+    const defines = iterateDefineRefs(context.getScope())
+
+    /**
+     * @param {string} name
+     * @param {CallExpression} node
+     */
+    function report(name, node) {
+      context.report({
+        node,
+        messageId: 'noType',
+        data: {
+          name
+        }
+      })
+    }
+
+    return {
+      Program() {
+        for (const ref of defines) {
+          if (ref.name !== 'ref' && ref.name !== 'shallowRef') {
+            continue
+          }
+
+          if (
+            ref.node.arguments.length > 0 &&
+            !isNullOrUndefined(ref.node.arguments[0])
+          ) {
+            continue
+          }
+
+          if (ref.node.typeParameters == null) {
+            if (
+              ref.node.parent.type === 'VariableDeclarator' &&
+              ref.node.parent.id.type === 'Identifier'
+            ) {
+              if (ref.node.parent.id.typeAnnotation == null) {
+                report(ref.name, ref.node)
+              }
+            } else {
+              report(ref.name, ref.node)
+            }
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/lib/utils/index.js b/lib/utils/index.js
index ef8cd0ee..1c54ef3f 100644
--- a/lib/utils/index.js
+++ b/lib/utils/index.js
@@ -998,6 +998,8 @@ module.exports = {
     return null
   },
 
+  isTypeScriptFile,
+
   isVueFile,
 
   /**
@@ -2416,6 +2418,13 @@ function getVExpressionContainer(node) {
   return n
 }
 
+/**
+ * @param {string} path
+ */
+function isTypeScriptFile(path) {
+  return path.endsWith('.ts') || path.endsWith('.tsx') || path.endsWith('.mts')
+}
+
 // ------------------------------------------------------------------------------
 // Vue Helpers
 // ------------------------------------------------------------------------------
diff --git a/lib/utils/ref-object-references.js b/lib/utils/ref-object-references.js
index ceecd89e..49404dad 100644
--- a/lib/utils/ref-object-references.js
+++ b/lib/utils/ref-object-references.js
@@ -251,6 +251,7 @@ function getGlobalScope(context) {
 }
 
 module.exports = {
+  iterateDefineRefs,
   extractRefObjectReferences,
   extractReactiveVariableReferences
 }


Test patch:
diff --git a/tests/lib/rules/require-typed-ref.js b/tests/lib/rules/require-typed-ref.js
new file mode 100644
index 00000000..571b5c5d
--- /dev/null
+++ b/tests/lib/rules/require-typed-ref.js
@@ -0,0 +1,239 @@
+/**
+ * @author Ivan Demchuk <https://github.com/Demivan>
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/require-typed-ref')
+
+const tester = new RuleTester({
+  parser: require.resolve('@typescript-eslint/parser'),
+  parserOptions: { ecmaVersion: 2020, sourceType: 'module' }
+})
+
+// Note: Need to specify filename for each test,
+// as only TypeScript files are being checked
+tester.run('require-typed-ref', rule, {
+  valid: [
+    {
+      filename: 'test.ts',
+      code: `
+        import { shallowRef } from 'vue'
+        const count = shallowRef(0)
+      `
+    },
+    {
+      filename: 'test.ts',
+      code: `
+        import { ref } from 'vue'
+        const count = ref<number>()
+      `
+    },
+    {
+      filename: 'test.ts',
+      code: `
+        import { ref } from 'vue'
+        const count = ref<number>(0)
+      `
+    },
+    {
+      filename: 'test.ts',
+      code: `
+        import { ref } from 'vue'
+        const counter: Ref<number | undefined> = ref()
+      `
+    },
+    {
+      filename: 'test.ts',
+      code: `
+        import { ref } from 'vue'
+        const count = ref(0)
+      `
+    },
+    {
+      filename: 'test.ts',
+      code: `
+        import { ref } from 'vue'
+        function useCount() {
+          return {
+            count: ref<number>()
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.ts',
+      code: `
+      import { ref, defineComponent } from 'vue'
+      defineComponent({
+        setup() {
+          const count = ref<number>()
+          return { count }
+        }
+      })
+      `
+    },
+    {
+      filename: 'test.vue',
+      parser: require.resolve('vue-eslint-parser'),
+      code: `
+        <script setup>
+          import { ref } from 'vue'
+          const count = ref()
+        </script>
+      `
+    },
+    {
+      filename: 'test.js',
+      code: `
+        import { ref } from 'vue'
+        const count = ref()
+      `
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.ts',
+      code: `
+        import { ref } from 'vue'
+        const count = ref()
+      `,
+      errors: [
+        {
+          messageId: 'noType',
+          line: 3,
+          column: 23,
+          endLine: 3,
+          endColumn: 28
+        }
+      ]
+    },
+    {
+      filename: 'test.ts',
+      code: `
+        import { ref } from 'vue'
+        const count = ref(null)
+      `,
+      errors: [
+        {
+          messageId: 'noType',
+          line: 3,
+          column: 23,
+          endLine: 3,
+          endColumn: 32
+        }
+      ]
+    },
+    {
+      filename: 'test.ts',
+      code: `
+        import { ref } from 'vue'
+        const count = ref(undefined)
+      `,
+      errors: [
+        {
+          messageId: 'noType',
+          line: 3,
+          column: 23,
+          endLine: 3,
+          endColumn: 37
+        }
+      ]
+    },
+    {
+      filename: 'test.ts',
+      code: `
+        import { shallowRef } from 'vue'
+        const count = shallowRef()
+      `,
+      errors: [
+        {
+          messageId: 'noType',
+          line: 3,
+          column: 23,
+          endLine: 3,
+          endColumn: 35
+        }
+      ]
+    },
+    {
+      filename: 'test.ts',
+      code: `
+        import { ref } from 'vue'
+        function useCount() {
+          const count = ref()
+          return { count }
+        }
+      `,
+      errors: [
+        {
+          messageId: 'noType',
+          line: 4,
+          column: 25,
+          endLine: 4,
+          endColumn: 30
+        }
+      ]
+    },
+    {
+      filename: 'test.ts',
+      code: `
+        import { ref } from 'vue'
+        function useCount() {
+          return {
+            count: ref()
+          }
+        }
+      `,
+      errors: [
+        {
+          messageId: 'noType',
+          line: 5,
+          column: 20,
+          endLine: 5,
+          endColumn: 25
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      parser: require.resolve('vue-eslint-parser'),
+      code: `
+        <script setup lang="ts">
+          import { ref } from 'vue'
+          const count = ref()
+        </script>
+      `,
+      errors: [
+        {
+          messageId: 'noType',
+          line: 4,
+          column: 25,
+          endLine: 4,
+          endColumn: 30
+        }
+      ]
+    },
+    {
+      filename: 'test.ts',
+      code: `
+        import { ref, defineComponent } from 'vue'
+        defineComponent({
+          setup() {
+            const count = ref()
+            return { count }
+          }
+        })
+      `,
+      errors: [
+        {
+          messageId: 'noType',
+          line: 5,
+          column: 27,
+          endLine: 5,
+          endColumn: 32
+        }
+      ]
+    }
+  ]
+})