How to Add Static Type Checking for Slots in Vue Components

Yangholmes

Yangholmes

2025-05-28T07:30:25Z

4 min read

This article was translated by deepseek, original article.

Under the premise of writing Vue components using TypeScript + JSX


1. Writing Vue Components with JSX Syntax

In addition to template syntax, Vue components can also be written using JSX. JSX, originally developed by Facebook for React, has gained popularity due to its ease of adoption, leading many web frameworks to support this JavaScript dialect. JSX also offers another advantage: seamless type integration (TSX). Any frontend framework that compiles templates into createElement()-like functions should inherently support JSX, and Vue is no exception. Vue has supported JSX since version 2.x. The template options syntax in Vue 2 was cumbersome for flexible scenarios, often necessitating JSX or render function implementations (e.g., ant-design-vue's Modal.method()).

Vue 3 introduced the setup function, allowing JSX to be used not only in render functions but also directly in the return value of setup. Render functions tend to isolate component state, logic, lifecycle hooks, and structure, whereas the Composition API aims to bring these elements closer together. Thus, when using JSX in Vue 3, it is recommended to implement it within setup rather than a render function. A simple comparison:

// vue2
Vue.component('Hello', {
  render: function (h) {
    return h(
      'h' + this.level,
      this.title
    )
  },
  props: {
    level: {
      type: Number,
      required: true
    },
    title: {
      type: String,
      required: true
    }
  }
})
Enter fullscreen mode Exit fullscreen mode
// vue3
import { ref, h } from 'vue'

export default {
  props: {
    level: {
      type: Number,
      required: true
    },
    title: {
      type: String,
      required: true
    }
  },
  setup(props) {
    return () => h('h' + props.level, props.title)
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Adding Static Type Checks to Vue Components

Vue 2 had limited TypeScript support, making both template and render function approaches cumbersome for large-scale projects. Vue 3 significantly enhances TypeScript integration, offering robust type inference for both the new <script setup> syntax and traditional Options API. Previously, props validation was purely runtime-based. For example, defining a component's props:

props: {
  title: {
    type: String
  }
}
Enter fullscreen mode Exit fullscreen mode

If a non-String value was passed, a warning would appear in the browser console. This approach lacked static checks, making type errors prone in insufficiently tested code. Vue 3 improves this with minimal changes:

props: {
  title: {
    type: String as PropType<string>
  }
}
Enter fullscreen mode Exit fullscreen mode

Here, PropType is a utility type provided by Vue for static type checking. For example, defining a Hello component:

import { defineComponent, h, PropType } from 'vue';

export const HelloProps = {
  level: {
    type: Number as PropType<1 | 2 | 3 | 4 | 5>,
    required: true
  },
  title: {
    type: String as PropType<string>,
    required: true
  }
} as const;

export default defineComponent({
  name: 'Hello',
  props: HelloProps,
  setup(props) {
    return () => h(
      `h${props.level}`
      props.title,
    );
  }
});
Enter fullscreen mode Exit fullscreen mode

Incorrect props now trigger static errors during development:

props check

This approach is both elegant and type-safe. Similarly, Vue 3 provides defineSlots for slot type checking in SFCs:

<script setup lang="ts">
const slots = defineSlots<{
  default(props: { msg: string }): any
}>()
</script>
Enter fullscreen mode Exit fullscreen mode

For non-<script setup> components, the SlotsType utility enforces slot types:

defineComponent({
  name: 'B',
  slots: Object as SlotsType<{
    default: () => JSX.Element;
    footer: () => JSX.Element;
  }>,
  setup(props, { slots }) {
    return () => <>
      {slots.default?.()}
    </>
  }
})
Enter fullscreen mode Exit fullscreen mode

This provides type hints and checks for slots within setup. It is perfect ~ uh?

3. The Slots Type Dilemma in TSX

Despite defining slot types with SlotsType, external usage of slots in TSX components remains unchecked. For example, using component B with invalid slots does not trigger errors:

slots

Why does this happen? While SFCs with and defineSlots work seamlessly, TSX components lack equivalent static checks.This raises the question: ​​Can Vue 3 TSX components perform static type checks for slots?​

4. Slots vs. Children

JSX transforms elements into createElement-like calls. In React, children are treated as a props.children attribute, but Vue handles slots differently. Vue’s JSX runtime does not map slots to props, and its type definitions (jsx-runtime.d.ts) lack ElementChildrenAttribute declarations. React’s approach:

interface ElementChildrenAttribute extends React.JSX.ElementChildrenAttribute {}

interface ElementChildrenAttribute {
  children: {};
Enter fullscreen mode Exit fullscreen mode

Vue’s JSX runtime omits this, preventing TypeScript from associating slots with props.

5. Solutions

i. Explicitly Define children in Props

Manually declare slots as children in props:

interface BSlots {
  default?: () => JSX.Element,
  footer?: () => JSX.Element
}

export const BProps = {
  children: {
    type: Object as PropType<BSlots>,
  }
};

defineComponent({
  name: 'B',
  props: BProps,
  slots: Object as SlotsType<BSlots>,
  setup(props, { slots }) {
    return () => <>
      {slots.default?.()}
    </>
  }
})
Enter fullscreen mode Exit fullscreen mode

This enforces slot type checks externally:

Image description

But What is the cost? Pollutes props with a non-standard children property and introduces naming confusion.

ii. Modify Vue’s JSX Runtime

Adjust the JSX type definitions to associate slots with children. Update InferredProps in Vue’s type declarations to include slots:

export function defineComponent<
  // ... existing generics
  ResolvedEmits extends EmitsOptions = {} extends RuntimeEmitsOptions
    ? TypeEmitsToOptions<TypeEmits>
    : RuntimeEmitsOptions,
  // 注入发生在这里,给 InferredProps 增加一个 children 属性,指向 传入的 Slots 定义,当然放到最后面会更好,不会被覆盖
  InferredProps = {children?: UnwrapSlotsType<Slots>} & (IsKeyValues<TypeProps> extends true
    ? TypeProps
    : string extends RuntimePropsKeys
      ? ComponentObjectPropsOptions extends RuntimePropsOptions
        ? {}
        : ExtractPropTypes<RuntimePropsOptions>
      : { [key in RuntimePropsKeys]?: any }),
  TypeRefs extends Record<string, unknown> = {},
  // ... remaining generics
>
Enter fullscreen mode Exit fullscreen mode

This injects slot types into children, enabling static checks. However, built-in Vue components (e.g., Fragment) may require manual children type fixes:

export declare const Fragment: {
    __isFragment: true;
    new (): {
        $props: VNodeProps & {
            children?: VNodeChild; // Add children type
        };
    };
};
Enter fullscreen mode Exit fullscreen mode

What is the cost? Non-official solution requiring adjustments to internal types. Recommended only for projects fully committed to TSX. And, requires careful testing.