Vue defineModel Enforcement

Enforces use of Vue 3's defineModel() macro instead of manual v-model implementation.

Overview

This rule detects the old pattern of using modelValue prop with update:modelValue emit and suggests using the defineModel() macro instead.

Source Code

import { defineCodeRule } from "@syncrolabs/claude-code-validator";

export const defineModelRule = defineCodeRule({
  name: "define-model",
  description:
    "Enforce use of defineModel() macro instead of modelValue prop + emit pattern",

  shouldRun: (context) => {
    return context.filePath.endsWith(".vue");
  },

  validate(context) {
    const errors: string[] = [];

    // Check for modelValue in defineProps (various patterns)
    const hasModelValueProp =
      // Inline type: defineProps<{ modelValue: ... }>
      /defineProps\s*<[^>]*{\s*[^}]*modelValue[^}]*}[^>]*>/s.test(
        context.content
      ) ||
      // Runtime props: defineProps({ modelValue: ... })
      /defineProps\s*\([^)]*modelValue[^)]*\)/s.test(context.content) ||
      // Interface/Type usage with defineProps + interface/type with modelValue
      (/defineProps\s*<\s*Props\s*>/s.test(context.content) &&
        /interface\s+Props\s*{[^}]*modelValue[^}]*}/s.test(context.content)) ||
      // withDefaults pattern
      (/withDefaults\s*\(\s*defineProps\s*<\s*Props\s*>/s.test(
        context.content
      ) &&
        /interface\s+Props\s*{[^}]*modelValue[^}]*}/s.test(context.content));

    // Check for update:modelValue in defineEmits
    const hasUpdateModelValueEmit =
      /defineEmits\s*<[^>]*{\s*[^}]*['"]update:modelValue['"]/s.test(
        context.content
      ) ||
      /defineEmits\s*\([^)]*['"]update:modelValue['"]/s.test(context.content);

    if (hasModelValueProp && hasUpdateModelValueEmit) {
      errors.push(
        `❌ Using modelValue prop with update:modelValue emit is outdated\n` +
          `   → Use the defineModel() macro instead for two-way binding\n` +
          `   â„šī¸  Example: const model = defineModel<YourType>()\n` +
          `   📄 File: ${context.filePath}`
      );
    }

    return errors;
  },
});

What It Catches

Old Pattern (Blocked)

<script setup lang="ts">
interface Props {
  modelValue: string;
}

const props = defineProps<Props>();

const emit = defineEmits<{
  "update:modelValue": [value: string];
}>();

function updateValue(value: string) {
  emit("update:modelValue", value);
}
</script>

New Pattern (Allowed)

<script setup lang="ts">
const model = defineModel<string>();

function updateValue(value: string) {
  model.value = value;
}
</script>

Detected Patterns

The rule detects several prop definition patterns:

Inline Type Definition

// ❌ Blocked
const props = defineProps<{
  modelValue: string;
}>();

const emit = defineEmits<{
  "update:modelValue": [value: string];
}>();

Interface-Based

// ❌ Blocked
interface Props {
  modelValue: string;
}

const props = defineProps<Props>();
const emit = defineEmits<{ "update:modelValue": [value: string] }>();

With Defaults

// ❌ Blocked
interface Props {
  modelValue?: string;
}

const props = withDefaults(defineProps<Props>(), {
  modelValue: "",
});

const emit = defineEmits<{ "update:modelValue": [value: string] }>();

Runtime Props

// ❌ Blocked
const props = defineProps({
  modelValue: String,
});

const emit = defineEmits(["update:modelValue"]);

Benefits

  • Simpler code - Less boilerplate with defineModel()
  • Type safety - Better TypeScript inference
  • Consistency - Enforces modern Vue 3 patterns
  • Maintainable - Easier to read and understand

Migration Guide

Before (old pattern):

<script setup lang="ts">
interface Props {
  modelValue: string;
  label?: string;
}

const props = withDefaults(defineProps<Props>(), {
  label: "Default label",
});

const emit = defineEmits<{
  "update:modelValue": [value: string];
}>();

function handleInput(event: Event) {
  const target = event.target as HTMLInputElement;
  emit("update:modelValue", target.value);
}
</script>

<template>
  <div>
    <label>{{ label }}</label>
    <input :value="modelValue" @input="handleInput" />
  </div>
</template>

After (with defineModel):

<script setup lang="ts">
interface Props {
  label?: string;
}

const props = withDefaults(defineProps<Props>(), {
  label: "Default label",
});

const model = defineModel<string>();
</script>

<template>
  <div>
    <label>{{ label }}</label>
    <input v-model="model" />
  </div>
</template>

Test Cases

describe("Define Model Rule", () => {
  it("should detect inline type modelValue pattern", () => {
    const context = {
      toolName: "Write",
      filePath: "test.vue",
      content: `
        const props = defineProps<{ modelValue: string }>();
        const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
      `,
      operation: "write" as const,
    };

    const errors = defineModelRule.validate(context);
    expect(errors).toHaveLength(1);
  });

  it("should not flag defineModel usage", () => {
    const context = {
      toolName: "Write",
      filePath: "test.vue",
      content: "const model = defineModel<string>();",
      operation: "write" as const,
    };

    const errors = defineModelRule.validate(context);
    expect(errors).toHaveLength(0);
  });
});