Skip to content

How to Use the Variant Props Pattern in Vue

Published: at 

Building Vue components that handle multiple variations while maintaining type safety can be tricky. Let’s dive into the Variant Props Pattern (VPP) - a powerful approach that uses TypeScript’s discriminated unions with Vue’s composition API to create truly type-safe component variants.

TL;DR

The Variant Props Pattern in Vue combines TypeScript’s discriminated unions with Vue’s prop system to create type-safe component variants. Instead of using complex type utilities, we explicitly mark incompatible props as never to prevent prop mixing at compile time:

// Define base props
type 
type BaseProps = {
    title: string;
}
BaseProps
= {
title: stringtitle: string; } // Success variant prevents error props type
type SuccessProps = BaseProps & {
    variant: "success";
    message: string;
    errorCode?: never;
}
SuccessProps
=
type BaseProps = {
    title: string;
}
BaseProps
& {
variant: "success"variant: 'success'; message: stringmessage: string; errorCode?: undefinederrorCode?: never; // Prevents mixing } // Error variant prevents success props type
type ErrorProps = BaseProps & {
    variant: "error";
    errorCode: string;
    message?: never;
}
ErrorProps
=
type BaseProps = {
    title: string;
}
BaseProps
& {
variant: "error"variant: 'error'; errorCode: stringerrorCode: string; message?: undefinedmessage?: never; // Prevents mixing } type type Props = SuccessProps | ErrorPropsProps =
type SuccessProps = BaseProps & {
    variant: "success";
    message: string;
    errorCode?: never;
}
SuccessProps
|
type ErrorProps = BaseProps & {
    variant: "error";
    errorCode: string;
    message?: never;
}
ErrorProps
;

This pattern provides compile-time safety, excellent IDE support, and reliable vue-tsc compatibility. Perfect for components that need multiple, mutually exclusive prop combinations.

The Problem: Mixed Props Nightmare

Picture this: You’re building a notification component that needs to handle both success and error states. Each state has its own specific properties:

Without proper type safety, developers might accidentally mix these props:

<!-- This should fail! -->
<NotificationAlert
  variant="primary"
  title="Data Saved"
  message="Success!"
  errorCode="UPLOAD_001"  <!-- 🚨 Mixing success and error props -->
  :duration="5000"
  @close="handleClose"
/>

The Simple Solution That Doesn’t Work

Your first instinct might be to define separate interfaces:

interface SuccessProps {
  SuccessProps.title: stringtitle: string;
  SuccessProps.variant: "primary" | "secondary"variant: 'primary' | 'secondary';
  SuccessProps.message: stringmessage: string;
  SuccessProps.duration: numberduration: number;
}

interface ErrorProps {
  ErrorProps.title: stringtitle: string;
  ErrorProps.variant: "danger" | "warning"variant: 'danger' | 'warning';
  ErrorProps.errorCode: stringerrorCode: string;
  ErrorProps.retryable: booleanretryable: boolean;
}

// 🚨 This allows mixing both types!
type type Props = neverProps = SuccessProps & ErrorProps;

The problem? This approach allows developers to use both success and error props simultaneously - definitely not what we want!

Using Discriminated Unions with never

TypeScript Tip: The never type is a special type in TypeScript that represents values that never occur. When a property is marked as never, TypeScript ensures that value can never be assigned to that property. This makes it perfect for creating mutually exclusive props, as it prevents developers from accidentally using props that shouldn’t exist together.

The never type commonly appears in TypeScript in several scenarios:

  • Functions that never return (throw errors or have infinite loops)
  • Exhaustive type checking in switch statements
  • Impossible type intersections (e.g., string & number)
  • Making properties mutually exclusive, as we do in this pattern

The main trick to make it work with the current implmenation of defineProps is to use never to explicitly mark unused variant props.

// Base props shared between variants
type 
type BaseProps = {
    title: string;
}
BaseProps
= {
title: stringtitle: string; } // Success variant type
type SuccessProps = BaseProps & {
    variant: "primary" | "secondary";
    message: string;
    duration: number;
    errorCode?: never;
    retryable?: never;
}
SuccessProps
=
type BaseProps = {
    title: string;
}
BaseProps
& {
variant: "primary" | "secondary"variant: 'primary' | 'secondary'; message: stringmessage: string; duration: numberduration: number; // Explicitly mark error props as never errorCode?: undefinederrorCode?: never; retryable?: undefinedretryable?: never; } // Error variant type
type ErrorProps = BaseProps & {
    variant: "danger" | "warning";
    errorCode: string;
    retryable: boolean;
    message?: never;
    duration?: never;
}
ErrorProps
=
type BaseProps = {
    title: string;
}
BaseProps
& {
variant: "danger" | "warning"variant: 'danger' | 'warning'; errorCode: stringerrorCode: string; retryable: booleanretryable: boolean; // Explicitly mark success props as never message?: undefinedmessage?: never; duration?: undefinedduration?: never; } // Final props type - only one variant allowed! type type Props = SuccessProps | ErrorPropsProps =
type SuccessProps = BaseProps & {
    variant: "primary" | "secondary";
    message: string;
    duration: number;
    errorCode?: never;
    retryable?: never;
}
SuccessProps
|
type ErrorProps = BaseProps & {
    variant: "danger" | "warning";
    errorCode: string;
    retryable: boolean;
    message?: never;
    duration?: never;
}
ErrorProps
;

Putting It All Together

Here’s our complete notification component using the Variant Props Pattern:

Conclusion

The Variant Props Pattern (VPP) provides a robust approach for building type-safe Vue components. While the Vue team is working on improving native support for discriminated unions in vuejs/core#8952, this pattern offers a practical solution today:

Unfortunately, what currently is not working is using helper utility types like Xor so that we don’t have to manually mark unused variant props as never. When you do that, you will get an error from vue-tsc.

Example of a helper type like Xor:

type type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: undefined; }Without<function (type parameter) T in type Without<T, U>T, function (type parameter) U in type Without<T, U>U> = { [function (type parameter) PP in type Exclude<T, U> = T extends U ? never : T
Exclude from T those types that are assignable to U
Exclude
<keyof function (type parameter) T in type Without<T, U>T, keyof function (type parameter) U in type Without<T, U>U>]?: never };
type type XOR<T, U> = T | U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | UXOR<function (type parameter) T in type XOR<T, U>T, function (type parameter) U in type XOR<T, U>U> = function (type parameter) T in type XOR<T, U>T | function (type parameter) U in type XOR<T, U>U extends object ? (type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: undefined; }Without<function (type parameter) T in type XOR<T, U>T, function (type parameter) U in type XOR<T, U>U> & function (type parameter) U in type XOR<T, U>U) | (type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: undefined; }Without<function (type parameter) U in type XOR<T, U>U, function (type parameter) T in type XOR<T, U>T> & function (type parameter) T in type XOR<T, U>T) : function (type parameter) T in type XOR<T, U>T | function (type parameter) U in type XOR<T, U>U; // Success notification properties type
type SuccessProps = {
    title: string;
    variant: "primary" | "secondary";
    message: string;
    duration: number;
}
SuccessProps
= {
title: stringtitle: string; variant: "primary" | "secondary"variant: 'primary' | 'secondary'; message: stringmessage: string; duration: numberduration: number; }; // Error notification properties type
type ErrorProps = {
    title: string;
    variant: "danger" | "warning";
    errorCode: string;
    retryable: boolean;
}
ErrorProps
= {
title: stringtitle: string; variant: "danger" | "warning"variant: 'danger' | 'warning'; errorCode: stringerrorCode: string; retryable: booleanretryable: boolean; }; // Final props type - only one variant allowed! ✨ type type Props = (Without<SuccessProps, ErrorProps> & ErrorProps) | (Without<ErrorProps, SuccessProps> & SuccessProps)Props = type XOR<T, U> = T | U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | UXOR<
type SuccessProps = {
    title: string;
    variant: "primary" | "secondary";
    message: string;
    duration: number;
}
SuccessProps
,
type ErrorProps = {
    title: string;
    variant: "danger" | "warning";
    errorCode: string;
    retryable: boolean;
}
ErrorProps
>;

Related Posts