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: string
title: 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: string
message: string;
errorCode?: undefined
errorCode?: 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: string
errorCode: string;
message?: undefined
message?: never; // Prevents mixing
}
type type Props = SuccessProps | ErrorProps
Props = 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:
- Success notifications need a
message
andduration
- Error notifications need an
errorCode
and aretryable
flag
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: string
title: string;
SuccessProps.variant: "primary" | "secondary"
variant: 'primary' | 'secondary';
SuccessProps.message: string
message: string;
SuccessProps.duration: number
duration: number;
}
interface ErrorProps {
ErrorProps.title: string
title: string;
ErrorProps.variant: "danger" | "warning"
variant: 'danger' | 'warning';
ErrorProps.errorCode: string
errorCode: string;
ErrorProps.retryable: boolean
retryable: boolean;
}
// 🚨 This allows mixing both types!
type type Props = never
Props = 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 asnever
, 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: string
title: 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: string
message: string;
duration: number
duration: number;
// Explicitly mark error props as never
errorCode?: undefined
errorCode?: never;
retryable?: undefined
retryable?: 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: string
errorCode: string;
retryable: boolean
retryable: boolean;
// Explicitly mark success props as never
message?: undefined
message?: never;
duration?: undefined
duration?: never;
}
// Final props type - only one variant allowed!
type type Props = SuccessProps | ErrorProps
Props = 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) P
P in type Exclude<T, U> = T extends U ? never : T
Exclude from T those types that are assignable to UExclude<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 | U
XOR<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: string
title: string;
variant: "primary" | "secondary"
variant: 'primary' | 'secondary';
message: string
message: string;
duration: number
duration: number;
};
// Error notification properties
type type ErrorProps = {
title: string;
variant: "danger" | "warning";
errorCode: string;
retryable: boolean;
}
ErrorProps = {
title: string
title: string;
variant: "danger" | "warning"
variant: 'danger' | 'warning';
errorCode: string
errorCode: string;
retryable: boolean
retryable: 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 | U
XOR<type SuccessProps = {
title: string;
variant: "primary" | "secondary";
message: string;
duration: number;
}
SuccessProps, type ErrorProps = {
title: string;
variant: "danger" | "warning";
errorCode: string;
retryable: boolean;
}
ErrorProps>;