Single Source of Truth Pattern in TypeScript

  • TypeScript
  • Storybook
  • JavaScript
  • Figma
  • Code Connect

This month I am writing about a pattern in TypeScript and React that I've become a big fan of. It doesn't seem to have a standard name, but it's often referred to as the single source of truth pattern. It's a way to write possible prop values in one place, export them as an Enum and an array of options that things like Storybook stories, Figma Code Connect docs, and test suites can consume. If you add a new prop value, you update it one place and the other spots can work with it.

An example component

Here's a component we can look at as a reference:

It's a good old fashioned polymorphic button component, one that can serve as either a link (rendering an <a> tag) or a button (rendering a <button> tags). Here's component definition:

"use client";
import React from "react";
import type { PropsWithChildren } from "react";
import styles from "./Button.module.scss";
import Link from "next/link";
export interface ButtonProps {
prominence?: ButtonProminence;
size?: ButtonSize;
href?: string;
openInNewTab?: boolean;
type?: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
className?: string;
clickHandler?: React.MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
}
const prominenceClasses = {
primary: styles["primary"],
"primary-inverse": styles["primary-inverse"],
secondary: styles["secondary"],
"secondary-inverse": styles["secondary-inverse"],
tertiary: styles["tertiary"],
"tertiary-inverse": styles["tertiary-inverse"],
} as const;
export type ButtonProminence = keyof typeof prominenceClasses;
export const BUTTON_PROMINENCES = Object.keys(
prominenceClasses,
) as ButtonProminence[];
const sizeClasses = {
small: styles["small"],
medium: styles["medium"],
large: styles["large"],
} as const;
export type ButtonSize = keyof typeof sizeClasses;
export const BUTTON_SIZES = Object.keys(sizeClasses) as ButtonSize[];
/**
* Button component that renders either a link or button element
*
* @param {Object} props
* @param {ButtonProminence} [props.prominence='primary'] - Prominence variant
* @param {ButtonSize} [props.size='small'] - Size variant
* @param {string} [props.href=''] - Link destination URL. If provided, renders as link; otherwise renders as button
* @param {boolean} [props.openInNewTab=false] - Whether to open link in a new tab (link only);
* @param {string} props.children - Button text content
* @param {string} [props.className=''] - Additional CSS classes
* @param {React.ButtonHTMLAttributes<HTMLButtonElement>['type']} [props.type='button'] - The type attribute for button element
* @param {React.MouseEventHandler} [props.clickHandler] - Click handler function
*
* @returns {JSX.Element}
*/
export default function Button({
prominence = "primary",
size = "small",
href = "",
openInNewTab = false,
children,
className = "",
type = "button",
clickHandler = undefined,
}: PropsWithChildren<ButtonProps>) {
const commonClasses = styles["button"];
if (href) {
return (
<Link
target={openInNewTab ? "_blank" : ""}
rel={openInNewTab ? "noopener noreferrer" : ""}
onClick={(e) => {
if (clickHandler) {
clickHandler(e);
}
}}
href={href}
className={`${commonClasses} ${prominenceClasses[prominence]} ${sizeClasses[size]} ${className}`}
>
<span>{children}</span>
</Link>
);
} else {
return (
<button
onClick={(e) => {
if (clickHandler) {
clickHandler(e);
}
}}
className={`${commonClasses} ${prominenceClasses[prominence]} ${sizeClasses[size]} ${className}`}
type={type}
>
<span>{children}</span>
</button>
);
}
}

There's quite a bit in there, but I'll break it down by focusing on the size prop first. The <Button /> component defaults to a small size, but it can also render as medium or large:

The size options, small, medium, and large, are properties of a sizeClasses object, which serves as a dictionary of available options for the size prop, with their associated classes:

const sizeClasses = {
small: styles["small"],
medium: styles["medium"],
large: styles["large"],
} as const;
export type ButtonSize = keyof typeof sizeClasses;
export const BUTTON_SIZES = Object.keys(sizeClasses) as ButtonSize[];

I'm using SCSS modules here, but this is where utility classes would go if you're using Tailwind, Bootstrap, or some other utility CSS library. I have marked the sizeClasses object as read only using the as const assertion.

In the next line, I am extracting a union type of the keys within the sizeClasses object:

export type ButtonSize = keyof typeof sizeClasses; // "small" | "medium" | "large"

Finally, I am creating a BUTTON_SIZES variable that holds the array of the sizeClasses object keys and I'm exporting it for use in other files. More on that in a little bit:

export const BUTTON_SIZES = Object.keys(sizeClasses) as ButtonSize[]; // ["small", "medium", "large"]

With these things in place, I use the ButtonSize union type within the component prop type interface:

export interface ButtonProps {
size?: ButtonSize;
// ...other prop types
}

I also tuck it into my JSDoc:

/**
* Button component that renders either a link or button element
*
* @param {Object} props
* @param {ButtonSize} [props.size='small'] - Size variant
* ...other param definitions
* @returns {JSX.Element}
*/

By doing this, I've locked down all valid values for the size prop to the keys in the sizeClasses object. TypeScript will prevent me from trying to use a prop value outside of those options, like "extra-large", and the JSDoc definition will suggest the keys in sizeClasses in VSCode's autocomplete.

You probably noticed that I did this for the prominenceClasses as well:

export interface ButtonProps {
prominence?: ButtonProminence;
// ...other prop types
}
const prominenceClasses = {
primary: styles["primary"],
"primary-inverse": styles["primary-inverse"],
secondary: styles["secondary"],
"secondary-inverse": styles["secondary-inverse"],
tertiary: styles["tertiary"],
"tertiary-inverse": styles["tertiary-inverse"],
} as const;
export type ButtonProminence = keyof typeof prominenceClasses;
export const BUTTON_PROMINENCES = Object.keys(
prominenceClasses,
) as ButtonProminence[];
/**
* Button component that renders either a link or button element
*
* @param {Object} props
* @param {ButtonProminence} [props.prominence='primary'] - Prominence variant
* ...other param definitions
*
* @returns {JSX.Element}
*/

Same thing here. This gives me one spot to define the different prominence options, which is just the color theming for the <Button />. Here is promince="secondary":

And here is prominence="secondary-inverse":

Storybook controls

I am also a big fan of Storybook and colocating component stories with the components they describe. Here's a minimal story for this Button component:

import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import Index, { BUTTON_PROMINENCES, BUTTON_SIZES } from "./index";
const meta = {
component: Index,
args: {
children: "Click me",
},
argTypes: {
prominence: {
control: "select",
options: BUTTON_PROMINENCES,
},
size: {
control: "select",
options: BUTTON_SIZES,
},
},
} satisfies Meta<typeof Index>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Button: Story = {};
export const Link: Story = {
args: {
href: "#",
},
};

Here is the key part:

import Index, { BUTTON_PROMINENCES, BUTTON_SIZES } from "./index";
const meta = {
// ...other story meta
argTypes: {
prominence: {
control: "select",
options: BUTTON_PROMINENCES,
},
size: {
control: "select",
options: BUTTON_SIZES,
},
},
};

I've imported the BUTTON_SIZES and BUTTON_PROMINENCES arrays (the keys of the sizeClasses and prominenceClasses objects) and I am passing them as the options for the size and prominence select controls. This means that the size control options will always stay in sync with the sizeClasses object, and the prominence control options will always stay in sync with the prominenceClasses object.

Now if I want to add a new value for the size prop or the prominence prop, I only have to add it to the sizeClasses object or the prominenceClasses object within the component definition, and anything that consumes BUTTON_SIZES or BUTTON_PROMINENCES will be automatically in sync, and those new values are also automatically valid to the TypeScript compiler.

I really love this "define once, apply everywhere" approach to something like this, especially as modern React frontend dev typically involves so many files and artifacts for a single component (tests, JSDoc, Figma Code Connect, etc.). It really comes in handy; you manage your prop API in one place and TypeScript, Storybook, VSCode autocomplete, and anything else that consumes those exports stays in line.

Other files

For completion's sake, here is the SCSS modules file I'm using for the different size and prominence styles:

.button {
display: inline-block;
border-width: var(--stroke-100);
border-style: solid;
border-radius: var(--corner-200);
text-decoration: none;
font-weight: normal;
font-family: var(--font-sans-serif);
&:hover {
cursor: pointer;
}
}
.primary {
background-color: var(--color-black);
border-color: var(--color-black);
color: var(--color-white);
&:hover {
background-color: var(--color-white);
color: var(--color-black);
}
}
.primary-inverse {
background-color: var(--color-white);
border-color: var(--color-black);
color: var(--color-black);
&:hover {
background-color: var(--color-black);
color: var(--color-white);
}
}
.secondary {
background-color: var(--color-blue);
border-color: var(--color-blue);
color: var(--color-white);
&:hover {
background-color: var(--color-white);
color: var(--color-blue);
}
}
.secondary-inverse {
background-color: var(--color-white);
border-color: var(--color-blue);
color: var(--color-blue);
&:hover {
background-color: var(--color-blue);
color: var(--color-white);
}
}
.tertiary {
background-color: var(--color-lemon);
border-color: var(--color-black);
color: var(--color-black);
&:hover {
background-color: var(--color-chiffon);
}
}
.tertiary-inverse {
border-color: var(--color-black);
background-color: var(--color-chiffon);
color: var(--color-black);
&:hover {
background-color: var(--color-lemon);
}
}
.small {
padding: var(--space-100) var(--space-200);
font-size: 1rem;
line-height: 1.5;
}
.medium {
padding: var(--space-200) var(--space-300);
font-size: 1.2rem;
line-height: 1.263;
}
.large {
padding: var(--space-300) var(--space-400);
font-size: 1.44rem;
line-height: 1.391;
}

And here is a test file. I use vitest. You can see BUTTON_SIZES and BUTTON_PROMINENCES in action in here:

import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import Button, { BUTTON_PROMINENCES, BUTTON_SIZES } from "./index";
afterEach(cleanup);
vi.mock("next/link", () => ({
default: ({
href,
children,
...props
}: {
href: string;
children: React.ReactNode;
[key: string]: unknown;
}) => (
<a href={href} {...props}>
{children}
</a>
),
}));
// This repo's Vitest config doesn't process SCSS modules, so the real
// import resolves to `{}`, making every class lookup collapse to
// `undefined`. Mock it as an identity proxy so class names stay
// distinguishable in assertions below.
vi.mock("./Button.module.scss", () => ({
default: new Proxy(
{},
{
get: (_target, prop: string) => prop,
},
),
}));
describe("Button", () => {
it("renders children", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeDefined();
});
it("renders a <button> element when no href is given", () => {
render(<Button>Submit</Button>);
const el = screen.getByRole("button");
expect(el.tagName).toBe("BUTTON");
});
it("renders a link when href is provided", () => {
render(<Button href="/about">About</Button>);
const el = screen.getByRole("link", { name: "About" });
expect(el.tagName).toBe("A");
expect(el.getAttribute("href")).toBe("/about");
});
it("calls clickHandler when clicked", () => {
const handler = vi.fn();
render(<Button clickHandler={handler}>Go</Button>);
fireEvent.click(screen.getByRole("button"));
expect(handler).toHaveBeenCalledTimes(1);
});
it("does not throw when clicked with no clickHandler", () => {
render(<Button>Safe</Button>);
expect(() => fireEvent.click(screen.getByRole("button"))).not.toThrow();
});
it("calls clickHandler when the link variant is clicked", () => {
const handler = vi.fn();
render(
<Button href="/about" clickHandler={handler}>
About
</Button>,
);
fireEvent.click(screen.getByRole("link"));
expect(handler).toHaveBeenCalledTimes(1);
});
it("applies the default prominence and size classes", () => {
render(<Button>Click me</Button>);
const el = screen.getByRole("button");
expect(el.className).toContain("primary");
expect(el.className).toContain("small");
});
it.each(BUTTON_PROMINENCES)(
"applies the %s prominence class to the button variant",
(prominence) => {
render(<Button prominence={prominence}>Click me</Button>);
expect(screen.getByRole("button").className).toContain(prominence);
},
);
it.each(BUTTON_SIZES)(
"applies the %s size class to the button variant",
(size) => {
render(<Button size={size}>Click me</Button>);
expect(screen.getByRole("button").className).toContain(size);
},
);
it("applies prominence and size classes to the link variant", () => {
render(
<Button href="/about" prominence="secondary" size="large">
About
</Button>,
);
const el = screen.getByRole("link");
expect(el.className).toContain("secondary");
expect(el.className).toContain("large");
});
it("passes through a custom className", () => {
render(<Button className="my-extra-class">Click me</Button>);
expect(screen.getByRole("button").className).toContain("my-extra-class");
});
it("defaults to type=button on the button variant", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button").getAttribute("type")).toBe("button");
});
it("applies a custom type to the button variant", () => {
render(<Button type="submit">Submit</Button>);
expect(screen.getByRole("button").getAttribute("type")).toBe("submit");
});
it("does not set target/rel on the link variant by default", () => {
render(<Button href="/about">About</Button>);
const el = screen.getByRole("link");
expect(el.getAttribute("target")).toBe("");
expect(el.getAttribute("rel")).toBe("");
});
it("opens in a new tab when openInNewTab is true", () => {
render(
<Button href="/about" openInNewTab>
About
</Button>,
);
const el = screen.getByRole("link");
expect(el.getAttribute("target")).toBe("_blank");
expect(el.getAttribute("rel")).toBe("noopener noreferrer");
});
});