2

I have 2 props: modelValue and range. The type of modelValue is conditional on the value of range. If true, I want modelValue to be of the type [number, number]; if false, I want modelValue to have the type number.

interface SharedProps {
  ...rest of props
}

type Props = SharedProps &
  (
    | { range: true; modelValue: [number, number] }
    | { range?: false; modelValue: number }
  )

const props = defineProps<Props>()

const emit = defineEmits<{
  (e: 'update:modelValue', value: typeof props.modelValue): void
}>()

// Destructure with defaults
const {
  modelValue,
  range,
...rest of props
} = props

Now in my component this works:

const tempValue = ref<number>(10)
    <VelSliderInput
      v-model="tempValue"
      :max="10"
      :min="0"
    />

and when I add range as a prop:

   <VelSliderInput
      v-model="tempValue"
      :max="10"
      range
      :min="0"
    />

I get the error as expected:

Argument of type '{ modelValue: number; max: number; range: true; min: number; }' is not assignable to parameter of type ...
  Types of property 'modelValue' are incompatible.
    Type 'number' is not assignable to type '[number, number]'.

Now when I use :model-value and @update:model-value:

    <VelSliderInput
      :model-value="tempValue"
      :max="10"
      :min="0"
      @update:model-value="tempValue = $event"
    />

I get the error:

Type 'number | [number, number]' is not assignable to type 'number'.
  Type '[number, number]' is not assignable to type 'number'.

Probably because in my emit I'm using a union type and not a discriminated union / conditional type.

Question: how can I emit a discriminated union / conditional type, so that I don't need to type cast in my component?

2 Answers 2

1

In your example you pass min and max...

Vue has no way to say: “If range in props is true, then type model [number, number], otherwise number” and also for TS to understand this automatically in the component body.

I would make it universal so that model is always number[]. Something like this:

// App.vue
<script setup lang="ts">
import Comp from './Comp.vue'
import { ref } from 'vue'

const val = ref([5])
const rangeVal = ref([2, 8])
</script>

<template>
  {{val}}
  <Comp v-model="val" />
  {{rangeVal}}
  <Comp v-model="rangeVal" range />
</template>
// Comp.vue
<script setup lang="ts">
import { defineModel } from 'vue'

defineProps<{
  range?: boolean
}>()

const model = defineModel<number[]>({
  default: [],
})
</script>

<template>
  <div v-if="range">
    <input type="number" v-model="model[0]" />
    <input type="number" v-model="model[1]" />
  </div>
  <div v-else>
    <input type="number" v-model="model[0]" />
  </div>
</template>

Vue SFC Playground

Sign up to request clarification or add additional context in comments.

Comments

0
<template>
   <!-- First mode without props range -->
  <SliderInput
  
    :min="0"
    :max="10"
     v-model="tempValue1"
  />
   <!-- Second mode with range props -->
  <SliderInput
   
    :min="0"
    :max="10"
    range
     v-model="tempValue2"
  />
</template>
<script setup lang="ts">
  import { ref } from 'vue';
  import SliderInput from './components/SliderInput.vue';

  const tempValue1 = ref<number>(10);
  const tempValue2 = ref<[number, number]>([2, 7]);
</script>
You can solve this with discriminated union props and multiple signatures in defineEmits. This way TypeScript correctly infers the type of modelValue depending on whether range is set or not:
<script setup lang="ts">
import { ref, defineProps, defineEmits } from 'vue'

interface SharedProps {
  min?: number
  max?: number
}

type Props = SharedProps &
  (
    | { range: true; modelValue: [number, number] }
    | { range?: false; modelValue: number }
  )

const props = defineProps<Props>()

const emit = defineEmits<{
  (e: 'update:modelValue', value: number): void
  (e: 'update:modelValue', value: [number, number]): void
}>()

const internalValue = ref(props.modelValue)
const updateValue = (e: Event) => {
  const target = e.target as HTMLInputElement
  const newVal = Number(target.value)
  if (props.range) {
    const newRange: [number, number] = [newVal, newVal + 1]
    internalValue.value = newRange
    emit('update:modelValue', newRange)
  } else {
    internalValue.value = newVal
    emit('update:modelValue', newVal)
  }
}
</script>

<template>
  <div>
    <input
      type="range"
      :min="props.min ?? 0"
      :max="props.max ?? 100"
      :value="Array.isArray(internalValue) ? internalValue[0] : internalValue"
      @input="updateValue"
    />
    <p>Value: {{ internalValue }}</p>
  </div>
</template>

🔹 Result:

  • If range is passed, modelValue must be [number, number].

  • If range is not passed, modelValue must be number.

  • TypeScript enforces this without any casting.

1 Comment

Please provide an explanation for how this answers the question.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.