Back to blog

Writing stories in TypeScript

Learn how to type your stories to make them easier to code and more robust

loading
Kyle Gach
โ€” @kylegach
Last updated:

You need to keep a lot of details in your head when developing UI componentsโ€”prop names, styling, state management, event handlers, etc. By using TypeScript to build your components, you can capture those details better and automate your workflow. Your code editor will type-check your code and offer better autocomplete, saving significant development time and effort.

You get the same improved ergonomics when using TypeScript with Storybook. That's why leading teams such as Microsoft, Github, and Codecademy use TypeScript to write stories.

This article shows you how to write stories with TypeScript. It covers everything from basics to best practices, with code examples for React, Angular, and Vue.

Why write stories in TypeScript?

Writing your stories in TypeScript makes you more productive. You don't have to jump between files to look up component props. Your code editor will alert you about missing required props and even autocomplete prop values. Plus, Storybook infers those component types to auto-generate an ArgsTable.

Screenshot of code editor showing a TypeScript error in a stories file

Using TypeScript also makes your code more robust. When authoring stories, you're replicating how a component will be used within your app. With type checking, you can catch bugs and edge cases as you code.

Storybook has built-in TypeScript support, so you can get started with zero configuration required. Letโ€™s dive into the specifics of typing a story.

Typing stories using Meta and Story utility types

When writing stories, there are two aspects that are helpful to type. The first is the component meta, which describes and configures the component and its stories. In a CSF file, this is the default export. The second is the stories themselves. Storybook provides utility types for each of these, named Meta and Story. Here are some basic examples of how to use those types:

React

// Button.stories.tsx
import * as React from "react";
import { Meta, Story } from "@storybook/react";
import { Button } from "./Button";

export default {
  component: Button,
} as Meta;

export const Primary: Story = (args) => <Button {...args} />;
Primary.args = {
  label: "Button",
  primary: true,
};

For template-based frameworks like Angular and Vue, the shape of the story changes somewhat, but the typing strategy remains the same.

Angular

// Button.stories.ts
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { CommonModule } from "@angular/common";
import { Button } from "./button.component";

export default {
  component: Button,
  decorators: [
    moduleMetadata({
      declarations: [Button],
      imports: [CommonModule],
    }),
  ],
} as Meta;

export const Primary: Story = (args) => ({
  props: args,
  template: `<app-button></app-button>`,
});
Primary.args = {
  label: "Button",
  primary: true,
};

Vue 3

// Button.stories.ts
import { Meta, Story } from "@storybook/vue3";
import Button from "./Button.vue";

export default {
  component: Button,
} as Meta;

export const Primary: Story = (args) => ({
  components: { Button },
  setup() {
    return { args };
  },
  template: `<Button v-bind="args" />`,
});
Primary.args = {
  primary: true,
  label: "Button",
};

Enabling more specific type checking

Meta and Story types are both generics, so you can provide them with a prop type parameter. By doing so, TypeScript will prevent you from defining an invalid prop, and all decorators, play functions, or loaders will be more fully typed.

// Button.stories.ts
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { CommonModule } from "@angular/common";
import { Button } from "./button.component";

export default {
  component: Button,
  decorators: [
    moduleMetadata({
      declarations: [Button],
      imports: [CommonModule],
    }),
  ],
} as Meta<Button>;

export const Primary: Story<Button> = (args) => ({
  props: args,
});
Primary.args = {
  label: "Button",
  primary: true,
  size: "xl",
  // ^ TypeScript error: type of `Button` does not contain `size`
};

The above code will show a TypeScript error because Button does not support a size input

Typing a template story function

It is common to extract a template function to share across multiple stories. For example, the story in the snippets above could be written as:

const Template: Story<Button> = (args) => ({
  props: args,
});

export const Primary = Template.bind({});
Primary.args = {
  label: "Primary Button",
  primary: true,
};

export const Secondary = Template.bind({});
Secondary.args = {
  label: "Secondary Button",
  primary: false,
};

In this snippet, the Primary and Secondary stories are cloning the Template function. By enabling the strictBindCallApply TypeScript option, your stories can automatically inherit the type of Template. In other words, you wonโ€™t have to redeclare the type on each story. You can enable this option in your tsconfig.

Typing custom args

Sometimes stories need to define args that arenโ€™t included in the componentโ€™s props. For example, a List component can contain ListItem components and you may wish to provide a control for the number of ListItems rendered.

For this case, you can use an intersection type to expand what you supply as a type variable for args:

// List.stories.ts
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { CommonModule } from "@angular/common";
import { List } from "./list.component";
import { ListItem } from "./list-item.component";

export default {
  component: List,
  subcomponents: { ListItem },
  decorators: [
    moduleMetadata({
      declarations: [List, ListItem],
      imports: [CommonModule],
    }),
  ],
} as Meta<List>;

// Expand Storyโ€™s type variable with `& { numberOfItems: number }`
export const NumberOfItems: Story<List & { numberOfItems: number }> = ({
  numberOfItems,
  ...args
}) => {
  // Generate an array of item labels, with length equal to the numberOfItems
  const itemLabels = [...Array(numberOfItems)].map(
    (_, index) => `Item ${index + 1}`
  );
  return {
    // Pass the array of item labels to the template
    props: { ...args, itemLabels },
    // Iterate over those labels to render each ListItem
    template: `<app-list>
     <div *ngFor="let label of itemLabels">
       <app-list-item [label]="label"></app-list-item>
     </div>
   </app-list>`,
  };
};
NumberOfItems.args = {
  numberOfItems: 1,
};
NumberOfItems.argTypes = {
  numberOfItems: {
    name: "Number of ListItems",
    options: [1, 2, 3, 4, 5],
    control: { type: "inline-radio" },
  },
};
Animation of changing the control for the number of ListItems and watching the story update

React-specific utility types

React components often don't export a type for their props. For this reason, Storybook for React exposes the ComponentMeta and ComponentStory types. They are equivalent to the Meta and Story generic types but can infer props type from the component itself.

// Button.stories.tsx
import * as React from "react";
import { ComponentMeta, ComponentStory } from "@storybook/react";
import { Button } from "./Button";
 
export default {
  component: Button,
} as ComponentMeta<typeof Button>;
 
export const Primary: ComponentStory<typeof Button> = (args) => (
  <Button {...args} />
);
Primary.args = {
  label: "Button",
  primary: true,
};

These utilities use the typeof operator to infer a component's prop types. Therefore, they cannot be used to accommodate custom args, as demonstrated in the previous section.

Instead, you can use the Meta and Story types along with React's ComponentProps utility. For example:

// Button.stories.tsx
import * as React from "react";
import { Meta, Story } from "@storybook/react";
import { Button } from "./Button";
 
type ButtonProps = React.ComponentProps<typeof Button>;
 
export default {
  component: Button,
} as Meta<ButtonProps>;
 
export const WithCustomArg: Story<ButtonProps & { customArg: number }> = (args) => (
  <Button {...args} />
);
Primary.args = {
  label: "Button",
  primary: true,
  customArg: 3,
};

Changes coming in Storybook v7

All of the snippets above are written in the CSF 2 format. Storybook 6.3 introduced CSF 3 as a more compact and composable way to write stories. Hereโ€™s the first snippet, rewritten to CSF 3:

// Button.stories.ts
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { CommonModule } from "@angular/common";
import { Button } from './button.component';
 
export default {
  component: Button,
  decorators: [
    moduleMetadata({
      declarations: [Button],
      imports: [CommonModule],
    }),
  ],
} as Meta<Button>;
 
export const Primary: StoryObj<Button> = {
  args: {
    label: "Button",
    primary: true,
  },
};

Note the StoryObj utility type. In Storybook 7.0, CSF 3 will become the default, the StoryObj type will be renamed to Story (to match its default nature), and the type used for CSF 2 will be renamed from Story to StoryFn.

There are also corresponding changes regarding ComponentStoryObj, ComponentStory, and ComponentStoryFn.

Finally, the CSF 3 versions of the types are still generic, accepting a type variable for the component or its args, depending on the type.

The documentation for Storybook 7.0 has already been updated to reflect these changes.

Wrapping up

Writing stories in TypeScript makes it easier to develop more robust components. You gain type safety and error checking, autocomplete suggestions, and more.

Storybook offers zero configuration TypeScript support. You can further customize this setup to better suit your needs. Plus, all code snippets in the Storybook docs are provided in both JavaScript and TypeScript flavors.

Screenshot of CSF file written in TypeScript from https://storybook.js.org/docs/react/writing-stories/introduction

If youโ€™d like to continue the discussion, please join us in the #typescript channel in the Storybook Discord. Iโ€™ll see you there!

Join the Storybook mailing list

Get the latest news, updates and releases

6,615 developers and counting

Weโ€™re hiring!

Join the team behind Storybook and Chromatic. Build tools that are used in production by 100s of thousands of developers. Remote-first.

View jobs

Popular posts

7.0 design alpha

Test drive the new layout, icons, and performance
loading
Dominic Nguyen

Why Storybook in 2022?

What's all the fuss about Storybook
loading
Dominic Nguyen

Storybook 7.0 design sneak peek

Visual updates, UX tweaks, and faster performance
loading
Dominic Nguyen
Join the community
6,615 developers and counting
WhyWhy StorybookComponent-driven UI
DocsGuidesTutorialsChangelogTelemetry
CommunityAddonsGet involvedBlog
ShowcaseExploreProjectsComponent glossary
Open source software
Storybook

Maintained by
Chromatic
Special thanks to Netlify and CircleCI