When you create components for your project, it all starts fun and easy. Create MyButton.vue
, add some styling, and voilà.
<template>
<button class="my-fancy-style">
<slot></slot>
</button>
</template>
Then you immediately realize that you need a dozen props, because your design team wants it to be of different colors and sizes, with icons on the left and right, with counters…
const props = withDefaults(defineProps<{
theme?: ComponentTheme;
small?: boolean;
icon?: IconSvg; // I’ve described how I cook icons in my previous article
rightIcon?: IconSvg;
counter?: number;
}>(), {
theme: ComponentTheme.BLUE,
icon: undefined,
rightIcon: undefined,
counter: undefined
});
It’s still ok; it makes sense. After all, you can’t have the “Cancel” and “Ok” buttons of the same color, and you need them to react to user interactions. Oh, right, interactions.
const props = withDefaults(defineProps<{
theme?: ComponentTheme;
small?: boolean;
icon?: IconSvg;
rightIcon?: IconSvg;
counter?: number;
disabled?: boolean;
loading?: boolean;
}>(), {
theme: ComponentTheme.BLUE,
icon: undefined,
rightIcon: undefined,
counter: undefined
});
Well, you get the idea: there will be something wild, like passing width: 100%
or adding autofocus—we all know how it looks simple in Figma until real life strikes hard.
Now imagine a link button: it looks the same, but when you press it, you should go to the external or internal link. You can wrap them into <RouterLink>
or <a>
tags every time, but please don’t. You can also add to
and href
props to your initial component, but you’ll feel suffocated pretty soon:
<component
:is="to ? RouterLink : href ? 'a' : 'button'"
<!-- ugh! -->
Of course, you’ll need a “second-level” component that wraps your button (it will also handle the default hyperlink outlines and some other interesting things, but I’ll omit them for the sake of simplicity):
<template>
<component
:is="props.to ? RouterLink : 'a'"
:to="props.to"
:href="props.href"
class="my-link-button"
>
<MyButton v-bind="$attrs">
<slot></slot>
</MyButton>
</component>
</template>
<script lang="ts" setup>
import MyButton from './MyButton.vue';
import { RouteLocationRaw, RouterLink } from 'vue-router';
const props = defineProps<{
to?: RouteLocationRaw;
href?: string;
}>();
</script>
And this is where our story begins.
Square One
Well, basically it’s going to work, I won’t lie. You can still type <MyLinkButton :counter=“2">
, and it’ll be fine. But there will be no autocomplete for the derived props, which is not cool:
We can propagate props silently, but the IDE doesn’t know a thing about them, and that’s a shame.
The straightforward and obvious solution is to propagate them explicitly:
<template>
<component
:is="props.to ? RouterLink : 'a'"
:to="props.to"
:href="props.href"
class="my-link-button"
>
<MyButton
:theme="props.theme"
:small="props.small"
:icon="props.icon"
:right-icon="props.rightIcon"
:counter="props.counter"
:disabled="props.disabled"
:loading="props.loading"
>
<slot></slot>
</MyButton>
</component>
</template>
<script lang="ts" setup>
// imports...
const props = withDefaults(
defineProps<{
theme?: ComponentTheme;
small?: boolean;
icon?: IconSvg;
rightIcon?: IconSvg;
counter?: number;
disabled?: boolean;
loading?: boolean;
to?: RouteLocationRaw;
href?: string;
}>(),
{
theme: ComponentTheme.BLUE,
icon: undefined,
rightIcon: undefined,
counter: undefined,
}
);
</script>
It will work. The IDE will have proper autocomplete. We will have a lot of pain and regret supporting it.
Obviously, the “Don’t Repeat Yourself” principle was not applied here, which means that we will need to synchronize every update. One day, you’ll need to add another prop, and you’ll have to find every component that wraps the basic one. Yes, Button and LinkButton are probably enough, but imagine TextInput and a dozen of components that depend on it: PasswordInput, EmailInput, NumberInput, DateInput, HellKnowsWhatElseInput. Adding a prop shouldn’t cause suffering.
After all, it’s ugly. And the more props we have, the uglier it becomes.
Clean It Up
It’s quite hard to reuse an anonymous type, so let’s give it a name.
// MyButton.props.ts
export interface MyButtonProps {
theme?: ComponentTheme;
small?: boolean;
icon?: IconSvg;
rightIcon?: IconSvg;
counter?: number;
disabled?: boolean;
loading?: boolean;
}
We cannot export an interface from the .vue
file due to some internal script setup
magic, so we need to create a separate .ts
file. On the bright side, look what we’ve got here:
const props = withDefaults(defineProps<MyButtonProps>(), {
theme: ComponentTheme.BLUE,
icon: undefined,
rightIcon: undefined,
counter: undefined,
});
Much cleaner, isn’t it? And here’s the inherited one:
interface MyLinkButtonProps {
to?: RouteLocationRaw;
href?: string;
}
const props = defineProps<MyButtonProps & MyLinkButtonProps>();
However, here’s a problem: now, when basic props are treated as MyLinkButton
’s props, they aren’t propagated with v-bind=”$attrs”
anymore, so we need to do it ourselves.
<!-- MyLinkButton.vue -->
<component
:is="props.to ? RouterLink : 'a'"
:to="props.to"
:href="props.href"
class="my-link-button"
>
<MyButton v-bind="props"> <!-- there we go -->
<slot></slot>
</MyButton>
</component>
It’s all good, but we pass a little bit more than we want to:
As you can see, now, our underlying button also has a href
attribute. It’s not a tragedy, just a bit of clutter and extra bytes, although not cool. Let’s clean it up, too.
<template>
<component
:is="props.to ? RouterLink : 'a'"
:to="props.to"
:href="props.href"
class="my-link-button"
>
<MyButton v-bind="propsToPass">
<slot></slot>
</MyButton>
</component>
</template>
<script lang="ts" setup>
// imports and definitions…
const props = defineProps<MyButtonProps & MyLinkButtonProps>();
const propsToPass = computed(() =>
Object.fromEntries(
Object.entries(props).filter(([key, _]) => !["to", "href"].includes(key))
)
);
</script>
Now, we only pass what has to be passed, but all those string literals don’t look awesome, do they? And that’s the saddest TypeScript story, guys. Let me tell you about it.
Interfaces vs Abstract Interfaces
If you have ever worked with proper object-oriented languages, you probably know about such things as reflection, which allows us to get the metadata about our structures. Unfortunately, in TypeScript, interfaces are ephemeral; they don’t exist at runtime, and we cannot easily find out which fields belong to the MyButtonProps
.
It means that we have two options. First, we can keep things as is: whenever we add a prop to MyLinkButton
, we also need to exclude it from propsToPass
(and even if we forget about it, it’s not a big deal).
The second way is to use objects instead of interfaces. It might sound senseless, but let me code something: it won’t be horrible; I promise. Well, at least not that horrible.
// MyButton.props.ts
export const defaultMyButtonProps: MyButtonProps = {
theme: ComponentTheme.BLUE,
small: false,
icon: undefined,
rightIcon: undefined,
counter: undefined,
disabled: false,
loading: false,
};
It doesn’t make sense to create an object just for creating an object, but we can use it for the default props. The declaration in MyButton.vue
becomes even cleaner:
const props = withDefaults(defineProps<MyButtonProps>(), defaultMyButtonProps);
Now, we only need to update propsToPass
in MyLinkButton.vue
:
const propsToPass = computed(() =>
Object.fromEntries(
Object.entries(props).filter(([key, _]) =>
Object.hasOwn(defaultMyButtonProps, key)
)
)
);
To make this work, we need to explicitly define all undefined
and null
fields in defaultMyButtonProps
; otherwise, the object doesn’t “haveOwn”.
This way, whenever you add a prop to the basic component, you’ll also need to add it to the object with default values. So, yes, it’s two places again, and maybe it’s not better than the solution from the previous chapter. It’s up to you which one you’ll find cleaner.
I’m Done
It’s not a masterpiece, but it’s probably the best we can do within TypeScript’s limitations.
It also seems that having prop types inside the SFC file is better, but I can’t say that moving them to a separate file made it much worse. But it definitely made prop reusing better, so I’ll consider it to be a small victory in an endless battle we call work.
You can find the code from this article on GitHub.