Skip to content
5 changes: 5 additions & 0 deletions .changeset/lucky-waves-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eslint-plugin-vue": patch
---

feat: extend `require-default-prop` and `require-valid-default-prop` to check `defineModel`
9 changes: 9 additions & 0 deletions docs/rules/require-default-prop.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ export default {
}
}
</script>
<script setup>
/* ✓ GOOD */
defineModel({ type: String, default: 'hello' })
defineModel({ type: Boolean }) // Boolean doesn't require a default

/* ✗ BAD */
defineModel({ type: String })
defineModel('count', { type: Number })
</script>
```

</eslint-code-block>
Expand Down
7 changes: 7 additions & 0 deletions docs/rules/require-valid-default-prop.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ export default {
}
}
</script>
<script setup>
/* ✓ GOOD */
defineModel({ type: Object, default: () => ({ message: 'hello' }) })

/* ✗ BAD */
defineModel({ type: Object, default: { message: 'hello' } })
</script>
```

</eslint-code-block>
Expand Down
27 changes: 27 additions & 0 deletions lib/rules/require-default-prop.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,33 @@ module.exports = {
}
return Boolean(defaultsByAssignmentPatterns[prop.propName])
})
},
onDefineModelEnter(node, model) {
if (model.options && model.options.type === 'ObjectExpression') {
if (
propIsRequired(model.options) ||
propHasDefault(model.options) ||
model.options.properties.some(
(p) =>
p.type === 'Property' &&
utils.getStaticPropertyName(p) === 'type' &&
isValueNodeOfBooleanType(p.value)
)
) {
return
}
context.report({
node: model.options,
messageId: 'missingDefault',
data: { propName: model.name.modelName }
})
} else {
context.report({
node: model.options || node,
messageId: 'missingDefault',
data: { propName: model.name.modelName }
})
}
Comment on lines +222 to +247
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onDefineModelEnter ignores model.typeNode (TypeScript generic) when deciding whether a default is required. This causes false positives for cases like defineModel<boolean>() / defineModel<boolean>({}), where Boolean models should be exempt from the default requirement (same as Boolean props elsewhere in this rule). Consider inferring runtime types from model.typeNode (e.g. via the existing ts-utils inference) and skipping the report when the inferred type is only Boolean (and still honoring required/default when provided).

Copilot uses AI. Check for mistakes.
}
}),
utils.executeOnVue(context, (obj) => {
Expand Down
91 changes: 86 additions & 5 deletions lib/rules/require-valid-default-prop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from '../utils/index.js'
import utils from '../utils/index.js'
import { capitalize } from '../utils/casing.ts'
import tsTypes from '../utils/ts-utils/ts-types.js'

const NATIVE_TYPES = new Set([
'String',
Expand Down Expand Up @@ -112,6 +113,10 @@ export default {
node: CallExpression
props: PropDefaultFunctionContext[]
}[] = []
const defineModelPropsContexts: {
node: CallExpression
props: PropDefaultFunctionContext[]
}[] = []

interface ScopeStack {
upper: ScopeStack | null
Expand Down Expand Up @@ -445,14 +450,24 @@ export default {
| FunctionDeclaration
| ArrowFunctionExpression
) {
const data = scriptSetupPropsContexts.at(-1)
if (!data || !scopeStack) {
if (!scopeStack) {
return
}

for (const { default: defType } of data.props) {
if (node.body === defType.functionBody) {
scopeStack.returnTypes = defType.returnTypes
const propsData = scriptSetupPropsContexts.at(-1)
if (propsData) {
for (const { default: defType } of propsData.props) {
if (node.body === defType.functionBody) {
scopeStack.returnTypes = defType.returnTypes
}
}
}
const modelData = defineModelPropsContexts.at(-1)
if (modelData) {
for (const { default: defType } of modelData.props) {
if (node.body === defType.functionBody) {
scopeStack.returnTypes = defType.returnTypes
}
}
}
},
Expand All @@ -469,6 +484,72 @@ export default {
for (const returnType of defType.returnTypes) {
if (typeNames.has(returnType.type)) continue

report(returnType.node, prop, typeNames)
}
}
},
onDefineModelEnter(node, model) {
let syntheticProp:
| ComponentObjectProp
| ComponentInferTypeProp
| null = null
let defaultFromOptions: Property | null = null

if (model.typeNode) {
syntheticProp = {
type: 'infer-type',
propName: model.name.modelName,
node: model.typeNode,
required: false,
types: tsTypes.inferRuntimeTypeFromTypeNode(
context,
model.typeNode
)
}
if (model.options && model.options.type === 'ObjectExpression') {
defaultFromOptions = getPropertyNode(model.options, 'default')
}
} else if (
model.options &&
model.options.type === 'ObjectExpression'
) {
syntheticProp = {
type: 'object',
propName: model.name.modelName,
key: model.options,
value: model.options,
// `node` is only accessed in report() when propName is null,
// which never occurs here since propName is always modelName.
node: node as any
}
}

if (!syntheticProp) {
return
}

const propContexts = processPropDefs([syntheticProp], function* () {
if (defaultFromOptions) {
yield {
src: 'defaultProperty',
expression: defaultFromOptions.value
}
}
})
defineModelPropsContexts.push({ node, props: propContexts })
},
onDefineModelExit() {
const data = defineModelPropsContexts.pop()
if (!data) {
return
}
for (const {
prop,
types: typeNames,
default: defType
} of data.props) {
for (const returnType of defType.returnTypes) {
if (typeNames.has(returnType.type)) continue
report(returnType.node, prop, typeNames)
}
}
Expand Down
Loading
Loading