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:
- Generic component props with proper type inference
- Discriminated unions for component variants
- Strict prop typing inherited from Radix UI primitives
- Custom utility types for className merging and variant composition
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
- Always export types alongside components. When creating wrappers or custom components, export the props interface so consumers can reference it with
import type. - Use
OmitandPickto narrow props. When wrapping Shadcn components, use these utility types to restrict or reshape the public API rather than redefining everything from scratch. - Leverage
ComponentPropsWithoutReffor polymorphic components. If you build components that render different elements based on props, use React's built-in utility types to preserve ref typing correctly. - Keep Zod schemas as the single source of truth. Derive form types, API response types, and validation logic from one schema definition to avoid drift between runtime validation and compile-time types.
- Avoid
anyin component props. If you need flexibility, use generics orunknownwith proper narrowing instead ofany, which disables type checking entirely. - Use
satisfiesfor configuration objects. When defining theme or variant configurations, thesatisfiesoperator ensures the object matches an expected type while preserving literal types for better inference downstream. - Document complex types with JSDoc. Since Shadcn components live in your codebase, adding JSDoc comments to custom types improves the IDE experience for your entire team.
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.