← Back to DevBytes

Shadcn UI TypeScript: Strongly Typed Applications

Shadcn UI TypeScript: Strongly Typed Applications

Shadcn UI has emerged as one of the most popular component libraries in the React ecosystem. Unlike traditional component libraries that ship as npm packages, Shadcn UI gives you the source code directly in your project. This unique approach pairs exceptionally well with TypeScript, enabling developers to build strongly typed applications with full control over their component architecture.

What Is Shadcn UI with TypeScript?

Shadcn UI is a collection of reusable, accessible components built on top of Radix UI primitives and styled with Tailwind CSS. When combined with TypeScript, it provides a fully typed component system where every prop, variant, and event handler is explicitly defined. The components live in your codebase, meaning you can modify types, extend interfaces, and customize behavior without fighting against a black-box library.

The library leverages several key TypeScript features:

Why Strong Typing Matters with Shadcn UI

When you install a Shadcn component, you get TypeScript definitions that are immediately useful. Strong typing prevents entire categories of bugs before runtime. For example, passing an invalid variant to a Button component will trigger a compile-time error rather than a silent rendering failure. This is especially valuable in larger teams where multiple developers interact with shared components.

Additionally, the typed props provide excellent IDE support. Autocompletion, inline documentation, and real-time error highlighting all stem from the TypeScript definitions embedded in each component file. Since you own the source code, you can refine these types to match your specific domain requirements.

Setting Up a Shadcn UI TypeScript Project

To get started, you need a React project with TypeScript configured. The easiest path is using Vite or Next.js. Below is an example using Vite:

# Create a new Vite project with TypeScript
npm create vite@latest my-shadcn-app -- --template react-ts

cd my-shadcn-app
npm install

# Install and initialize Tailwind CSS
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

# Initialize Shadcn UI
npx shadcn@latest init

During initialization, Shadcn will ask about your TypeScript configuration, styling preferences, and component aliases. Once complete, you can add components individually:

npx shadcn@latest add button
npx shadcn@latest add dialog
npx shadcn@latest add form

Each command downloads the component source code into your src/components/ui directory with full TypeScript typing intact.

Understanding the Generated Component Types

Let us examine a typical Shadcn Button component to understand how typing works in practice:

import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline: "border border-input bg-background hover:bg-accent",
        secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

export { Button, buttonVariants }

Several important TypeScript patterns are at work here. The ButtonProps interface extends the native React.ButtonHTMLAttributes, which means every standard button attribute like onClick, disabled, and type is automatically typed. The VariantProps utility from class-variance-authority generates a union type from the variant definitions, so only valid variant names are accepted.

Creating Strongly Typed Wrappers

A common pattern in production applications is wrapping Shadcn components to enforce domain-specific constraints. For instance, you might want a button that only accepts a subset of variants or requires certain props. Here is how to build a typed wrapper:

import { Button, type ButtonProps } from "@/components/ui/button"
import { forwardRef } from "react"

// Restrict to only safe variants for primary actions
type ActionButtonVariant = "default" | "destructive" | "outline"

interface ActionButtonProps
  extends Omit<ButtonProps, "variant"> {
  variant: ActionButtonVariant
  label: string // Make label required
  onAction: () => void
}

export const ActionButton = forwardRef<
  HTMLButtonElement,
  ActionButtonProps
>(({ label, onAction, variant, ...props }, ref) => {
  return (
    <Button
      ref={ref}
      variant={variant}
      onClick={onAction}
      {...props}
    >
      {label}
    </Button>
  )
})

ActionButton.displayName = "ActionButton"

Now any consumer of ActionButton must provide a label and an onAction handler, and can only choose from three variants. This is how you scale type safety across an application without sacrificing flexibility.

Typing Form Components with React Hook Form and Zod

Shadcn UI pairs naturally with React Hook Form and Zod for fully typed forms. The Form component from Shadcn is designed to work with these libraries. Here is a complete example:

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"

// Define the schema - this drives all types
const userSchema = z.object({
  email: z.string().email("Please enter a valid email"),
  password: z.string().min(8, "Password must be at least 8 characters"),
})

// Infer the type from the schema
type UserFormValues = z.infer<typeof userSchema>

export function UserForm() {
  const form = useForm<UserFormValues>({
    resolver: zodResolver(userSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  })

  function onSubmit(values: UserFormValues) {
    // values is fully typed as { email: string; password: string }
    console.log(values)
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input placeholder="you@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Password</FormLabel>
              <FormControl>
                <Input type="password" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">Submit</Button>
      </form>
    </Form>
  )
}

The key advantage here is that the name prop on FormField is strictly typed against the schema keys. If you mistype a field name, TypeScript will catch it immediately. The onSubmit callback also receives a fully typed values object derived from the Zod schema.

Building a Typed Data Table

Data tables are a common requirement in business applications. Shadcn provides a DataTable pattern built on TanStack Table that benefits enormously from TypeScript generics. Here is a typed implementation:

import { ColumnDef } from "@tanstack/react-table"
import { DataTable } from "@/components/ui/data-table"

// Define your data type
interface User {
  id: string
  name: string
  email: string
  role: "admin" | "editor" | "viewer"
  createdAt: Date
}

// Define columns with full type safety
const columns: ColumnDef<User>[] = [
  {
    accessorKey: "name",
    header: "Name",
  },
  {
    accessorKey: "email",
    header: "Email",
  },
  {
    accessorKey: "role",
    header: "Role",
    cell: ({ row }) => {
      const role = row.getValue("role") as User["role"]
      return <span className="capitalize">{role}</span>
    },
  },
  {
    accessorKey: "createdAt",
    header: "Created",
    cell: ({ row }) => {
      const date = row.getValue("createdAt") as Date
      return date.toLocaleDateString()
    },
  },
]

interface UserTableProps {
  data: User[]
  onRowClick?: (user: User) => void
}

export function UserTable({ data, onRowClick }: UserTableProps) {
  return <DataTable columns={columns} data={data} onRowClick={onRowClick} />
}

The generic ColumnDef<User> type ensures that every accessor key corresponds to a property on the User interface. Any typo in a column definition will surface as a compile error.

Extending Component Variants with Type Safety

Since you own the component source code, you can add custom variants while maintaining type safety. Here is an example of extending the Button component with a new success variant:

// In your customized button.tsx
const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground",
        destructive: "bg-destructive text-destructive-foreground",
        outline: "border border-input bg-background",
        secondary: "bg-secondary text-secondary-foreground",
        ghost: "hover:bg-accent",
        link: "text-primary underline-offset-4",
        // New variant added here
        success: "bg-green-600 text-white hover:bg-green-700",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 px-3",
        lg: "h-11 px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

Because VariantProps is derived from the buttonVariants definition, the new success variant is automatically available as a valid value wherever ButtonProps is used. No additional type updates are needed.

Best Practices for Shadcn UI with TypeScript

Handling Polymorphic Components

Some Shadcn components support polymorphic rendering through the asChild prop, which uses Radix's Slot component. Typing these correctly is important. Here is a pattern for building your own polymorphic component:

import * as React from "react"
import { Slot } from "@radix-ui/react-slot"

interface CardLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
  asChild?: boolean
  title: string
  description: string
}

export const CardLink = React.forwardRef<
  HTMLAnchorElement,
  CardLinkProps
>(({ asChild = false, title, description, children, ...props }, ref) => {
  const Comp = asChild ? Slot : "a"
  return (
    <Comp ref={ref} {...props}>
      <div className="rounded-lg border p-4">
        <h3 className="font-semibold">{title}</h3>
        <p className="text-sm text-muted-foreground">{description}</p>
        {children}
      </div>
    </Comp>
  )
})

CardLink.displayName = "CardLink"

This approach preserves all native anchor attributes while allowing the component to merge its props with a child element when asChild is true.

Conclusion

Shadcn UI combined with TypeScript offers a uniquely powerful developer experience. Because the component source code lives in your project, you have complete freedom to extend, restrict, and customize types to match your application's exact needs. By following the patterns outlined in this tutorial—typed wrappers, schema-driven forms, generic data tables, and safe variant extensions—you can build applications where type errors are caught at compile time rather than in production. The result is a codebase that is easier to maintain, safer to refactor, and more enjoyable to work with for every developer on your team.

— Ad —

Google AdSense will appear here after approval

← Back to all articles