type-system-patterns
リポジトリ: shadcn-ui/ui 分析日: 2026-03-04
概要
shadcn/ui における型システムの設計パターンを分析する。このリポジトリでは Zod スキーマを Single Source of Truth(SSOT)として活用し、z.infer による型導出、discriminatedUnion によるバリアント分岐、React.ComponentProps によるインターフェースレスなコンポーネント Props 型付けなど、一貫した型戦略が 2730 ファイルの大規模コードベース全体に適用されている。特筆すべきは、別途 interface を定義するのではなく、既存のスキーマ・コンポーネント・定数から型を機械的に導出する「型導出優先」のアプローチが徹底されている点である。
背景にある原則
Schema-First Typing(スキーマ起点の型設計): 型定義を手書きするのではなく、Zod スキーマから
z.inferで導出することで、ランタイムバリデーションと静的型検査を単一の定義で両立させる。手書きの型とバリデーションロジックの乖離を構造的に排除するため。根拠:schema.tsの全 export 型がz.infer<typeof ...>で導出されている(schema.ts:183,197,296,302)。Props Interface 不要の原則: React コンポーネントの Props 型に
interfaceを定義せず、React.ComponentProps<"element">やPrimitive.Propsから直接導出する。コンポーネントと DOM/プリミティブの Props が常に同期し、ラッパー層で Props の脱落が起きない。根拠: 全 UI コンポーネント(40+ ファイル)でinterfaceによる Props 定義が存在せず、インライン型注釈のみ。Composable Schema(スキーマの合成可能性):
.extend(),.pick(),.partial(),.deepPartial()を使って既存スキーマから派生スキーマを宣言的に作る。スキーマ間の関係がコードで表現され、フィールド追加・削除時に影響範囲が自動的に伝播する。根拠:rawConfigSchema→configSchema(.extend())、registryItemCommonSchema→registryItemSchemaの各バリアント(.extend())、registryResolvedItemsTreeSchema(.pick().extend())。型の導出チェーン最小化: 型は可能な限り元の定義(スキーマ、定数配列、ライブラリ型)から1段階で導出する。中間型を挟まず、変更が直接伝播するようにする。根拠:
type CarouselOptions = UseCarouselParameters[0]のようにParameters<>で直接抽出(carousel.tsx:14)、type BaseName = Base["name"]のようにインデックスアクセス型で直接取得(config.ts:23)。
実例と分析
Zod discriminatedUnion による型安全なバリアント分岐
registryItemSchema は type フィールドをディスクリミネータとして3つのバリアントを定義している。各バリアントは registryItemCommonSchema.extend() で共通フィールドを継承し、バリアント固有のフィールドだけを追加する。
// packages/shadcn/src/registry/schema.ts:169-181
export const registryItemSchema = z.discriminatedUnion("type", [
registryItemCommonSchema.extend({
type: z.literal("registry:base"),
config: rawConfigSchema.deepPartial().optional(),
}),
registryItemCommonSchema.extend({
type: z.literal("registry:font"),
font: registryItemFontSchema,
}),
registryItemCommonSchema.extend({
type: registryItemTypeSchema.exclude(["registry:base", "registry:font"]),
}),
]);ここでは .exclude() を使って「それ以外の全タイプ」を1つのバリアントで捕捉するパターンが使われている。新しいタイプを registryItemTypeSchema に追加しても、discriminatedUnion の各ブランチを手動で更新する必要がない。
registryItemFileSchema でも同様のパターンで「target が必須のタイプ」と「target がオプショナルなタイプ」を分けている:
// packages/shadcn/src/registry/schema.ts:92-106
export const registryItemFileSchema = z.discriminatedUnion("type", [
z.object({
path: z.string(),
content: z.string().optional(),
type: z.enum(["registry:file", "registry:page"]),
target: z.string(), // required
}),
z.object({
path: z.string(),
content: z.string().optional(),
type: registryItemTypeSchema.exclude(["registry:file", "registry:page"]),
target: z.string().optional(), // optional
}),
]);Extract<> による discriminatedUnion からのバリアント型抽出
z.infer で得たユニオン型から特定のバリアントだけを取り出すために Extract<> を組み合わせる:
// packages/shadcn/src/registry/schema.ts:183-189
export type RegistryItem = z.infer<typeof registryItemSchema>;
export type RegistryBaseItem = Extract<RegistryItem, { type: "registry:base"; }>;
export type RegistryFontItem = Extract<RegistryItem, { type: "registry:font"; }>;このパターンは UI 層でも使われている。ページツリーのノード型から特定のノード種を取り出す:
// apps/v4/lib/page-tree.ts:1-5
export type PageTreeNode = (typeof source.pageTree)["children"][number];
export type PageTreeFolder = Extract<PageTreeNode, { type: "folder"; }>;
export type PageTreePage = Extract<PageTreeNode, { type: "page"; }>;ジェネリクスと組み合わせた高度な例として、iframe メッセージングの型安全なディスパッチがある:
// apps/v4/app/(create)/hooks/use-iframe-sync.tsx:19-24
export function useIframeMessageListener<
Message extends ParentToIframeMessage,
MessageType extends Message["type"],
>(
messageType: MessageType,
onMessage: (data: Extract<Message, { type: MessageType; }>["data"]) => void,
);React.ComponentProps による Props 型のインライン定義
コンポーネント Props を別途定義せず、パラメータ型注釈で直接記述するパターンがコードベース全体の標準:
// apps/v4/registry/bases/base/ui/input.tsx:6
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
// apps/v4/registry/bases/base/ui/carousel.tsx:53
function Carousel({
...props
}: React.ComponentProps<"div"> & CarouselProps) {
// apps/v4/registry/bases/base/ui/carousel.tsx:179
function CarouselPrevious({
...props
}: React.ComponentProps<typeof Button>) {カスタム Props が必要な場合はインラインの交差型で追加:
// apps/v4/registry/bases/base/ui/sidebar.tsx:56-68
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {VariantProps<typeof cvaFn> による variant 型の自動導出
cva(class-variance-authority)で定義したバリアント設定から型を自動導出し、Props と結合する:
// apps/v4/registry/bases/base/ui/button.tsx:38-43
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {スキーマの段階的拡張パターン
基底スキーマから .extend() で段階的にフィールドを追加する:
// packages/shadcn/src/registry/schema.ts:28-67
export const rawConfigSchema = z
.object({
style: z.string(),
rsc: z.coerce.boolean().default(false),
// ... base fields
})
.strict();
export const configSchema = rawConfigSchema.extend({
resolvedPaths: z.object({
cwd: z.string(),
tailwindConfig: z.string(),
// ... resolved fields
}),
});内部で一時的に使うスキーマも .extend().passthrough() で柔軟に構成する:
// packages/shadcn/src/registry/resolver.ts:112-120
const registryItemWithSourceSchema = registryItemCommonSchema
.extend({
type: registryItemTypeSchema,
_source: z.string().optional(),
font: registryItemFontSchema.optional(),
config: z.any().optional(),
})
.passthrough();.pick().extend() による View 固有スキーマの導出
共通スキーマから必要なフィールドだけを選択し、ビュー固有のフィールドを追加する:
// packages/shadcn/src/registry/schema.ts:224-244
export const registryResolvedItemsTreeSchema = registryItemCommonSchema
.pick({
dependencies: true,
devDependencies: true,
files: true,
tailwind: true,
cssVars: true,
css: true,
envVars: true,
docs: true,
})
.extend({
fonts: z.array(
registryItemCommonSchema.extend({
type: z.literal("registry:font"),
font: registryItemFontSchema,
}),
).optional(),
});z.lazy() による再帰型の定義
CSS プロパティの再帰構造を z.lazy() で定義する:
// packages/shadcn/src/registry/schema.ts:125-131
const cssValueSchema: z.ZodType<any> = z.lazy(() =>
z.union([
z.string(),
z.array(z.union([z.string(), z.record(z.string(), z.string())])),
z.record(z.string(), cssValueSchema),
])
);z.lazy() の戻り値には型推論が効かないため、明示的に z.ZodType<any> と型注釈している。
CLI オプションの Zod スキーマ化
CLI コマンドのオプションを Zod スキーマで定義し、パース結果に型安全にアクセスする:
// packages/shadcn/src/commands/add.ts:24-34
export const addOptionsSchema = z.object({
components: z.array(z.string()).optional(),
yes: z.boolean(),
overwrite: z.boolean(),
cwd: z.string(),
all: z.boolean(),
path: z.string().optional(),
silent: z.boolean(),
srcDir: z.boolean().optional(),
cssVariables: z.boolean(),
})
// packages/shadcn/src/commands/add.ts:61-67
.action(async (components, opts) => {
const options = addOptionsSchema.parse({
components,
cwd: path.resolve(opts.cwd),
...opts,
})定数配列からの型導出
as const で定義した定数配列からインデックスアクセス型で型を取り出す:
// apps/v4/registry/config.ts:23-26
export type BaseName = Base["name"];
export type StyleName = Style["name"];
export type ThemeName = Theme["name"];
export type BaseColorName = BaseColor["name"];
// apps/v4/registry/config.ts:29-34
const fontValues = fonts.map((f) => f.name.replace("font-", "")) as [
string,
...string[],
];
export type FontValue = (typeof fontValues)[number];FormState の型構成パターン
フォームの状態型を Zod スキーマから構造的に導出する:
// apps/v4/registry/new-york-v4/examples/form-next-complex-schema.ts:30-34
export type FormState = {
values: z.infer<typeof formSchema>;
errors: null | Partial<Record<keyof z.infer<typeof formSchema>, string[]>>;
success: boolean;
};keyof z.infer<typeof formSchema> でフィールド名の型を取得し、Partial<Record<...>> でエラー辞書を構成している。フォームスキーマにフィールドを追加すると、エラー型にも自動的に反映される。
パターンカタログ
- Strategy パターン (分類: 振る舞い)
- 解決する問題: ソースコード変換パイプラインで、変換の種類を動的に差し替える
- 適用条件: 同じインターフェースで複数の処理を切り替える場面
- コード例:
packages/shadcn/src/utils/transformers/index.ts:27-31 - 注意点:
Transformer<Output = SourceFile>のようにデフォルト型パラメータを持たせると、既存の呼び出しを壊さずに出力型を変更できる
// packages/shadcn/src/utils/transformers/index.ts:27-31
export type Transformer<Output = SourceFile> = (
opts: TransformOpts & {
sourceFile: SourceFile;
},
) => Promise<Output>;Good Patterns
- スキーマからの型導出 + satisfies による型チェック:
z.inferで型を導出しつつ、オブジェクトリテラルにsatisfiesを付けて構造的整合性を検証する。ランタイムパースなしでも型安全が確保される場面で有効。
// packages/shadcn/src/registry/resolver.ts:512-536
const theme = {
name,
type: "registry:theme",
tailwind: { ... },
cssVars: { ... },
} satisfies z.infer<typeof registryItemSchema>- Omit + カスタムフィールドによる名前衝突の解決: ライブラリの Props 型でフィールド名が衝突する場合、
Omitで除外してから独自定義する。
// apps/v4/registry/bases/base/ui/native-select.tsx:6-8
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
size?: "sm" | "default";
};- type-fest の活用: 複雑な型変換に
type-festのユーティリティ型(PackageJson等)を使い、自前で型を再発明しない。
// packages/shadcn/src/utils/get-package-info.ts:3
import { type PackageJson } from "type-fest";- z.enum に as [T, ...T[]] キャストを使う動的 enum 構築: 実行時の配列から
z.enum()を構成する際、非空タプル型にキャストして Zod の型要件を満たす。
// apps/v4/registry/config.ts:67-68
base: z.enum(BASES.map((b) => b.name) as [BaseName, ...BaseName[]]),Anti-Patterns / 注意点
- z.lazy() に型注釈なしで使う:
z.lazy()は再帰参照を遅延させるが、TypeScript の型推論が循環参照で失敗するため、明示的な型注釈が必須。
// Bad: 型推論が any に崩壊する
const schema = z.lazy(() => z.object({ children: z.array(schema) }));
// Better: 明示的な ZodType 注釈
const schema: z.ZodType<TreeNode> = z.lazy(() => z.object({ children: z.array(schema) }));shadcn/ui では z.ZodType<any> を使っている(schema.ts:125)が、可能なら具体的な型を指定すべき。
- discriminatedUnion のバリアント数爆発:
registryItemTypeSchemaに 14 のタイプがあるが、discriminatedUnionでは3バリアントに集約し.exclude()で「その他」を処理している。バリアントごとに個別ブランチを作ると保守コストが急増する。
// Bad: 14バリアント全てを個別定義
z.discriminatedUnion("type", [
schema.extend({ type: z.literal("registry:lib") }),
schema.extend({ type: z.literal("registry:block") }),
// ... 12 more
])
// Better: 固有フィールドがあるものだけ分離し、残りは .exclude() で一括
z.discriminatedUnion("type", [
schema.extend({ type: z.literal("registry:base"), config: ... }),
schema.extend({ type: z.literal("registry:font"), font: ... }),
schema.extend({ type: typeSchema.exclude(["registry:base", "registry:font"]) }),
])導出ルール
[MUST]Zod スキーマを定義したら、型はz.infer<typeof schema>で導出する。手書きの型とスキーマを並存させない- 根拠: shadcn/ui の全 export 型(
RegistryItem,Config,Preset,Event等)が例外なくz.inferで導出されており、型とバリデーションの不整合がゼロ
- 根拠: shadcn/ui の全 export 型(
[MUST]z.lazy()を使う再帰スキーマにはz.ZodType<T>の型注釈を付ける- 根拠: 型注釈なしでは TypeScript が型推論を諦めて
anyに崩壊する(schema.ts:125で明示的にz.ZodType<any>を付与)
- 根拠: 型注釈なしでは TypeScript が型推論を諦めて
[SHOULD]React コンポーネントの Props 型はReact.ComponentProps<"element">またはPrimitiveComponent.Propsから導出し、カスタム Props はインラインの交差型(& { ... })で追加する。別途interfaceを定義しない- 根拠: shadcn/ui の 40+ UI コンポーネントで一貫してこのパターンが使われ、Props インターフェースの定義が不要になっている
[SHOULD]discriminatedUnion のバリアントが多い場合は、固有フィールドを持つバリアントのみ個別定義し、残りは.exclude()で1つのバリアントにまとめる- 根拠: 14 種の registry item type を3バリアントに集約し、新タイプ追加時の変更箇所を最小化している(
schema.ts:169-181)
- 根拠: 14 種の registry item type を3バリアントに集約し、新タイプ追加時の変更箇所を最小化している(
[SHOULD]CLI オプションやフォーム入力など外部データの境界には Zod スキーマを配置し、パース結果の型を後続処理で使う- 根拠:
addOptionsSchema,initOptionsSchema,designSystemConfigSchema等、全 CLI コマンドでこのパターンが適用されている
- 根拠:
[SHOULD]基底スキーマから.extend()/.pick()/.partial()で派生スキーマを作り、フィールドの重複定義を避ける- 根拠:
rawConfigSchema→configSchema(.extend())、registryItemCommonSchema→ 各バリアント(.extend())、registryResolvedItemsTreeSchema(.pick().extend())
- 根拠:
[SHOULD]discriminatedUnion から特定バリアントの型を取り出す場合はExtract<Union, { discriminator: value }>を使う- 根拠:
RegistryBaseItem,RegistryFontItem,PageTreeFolder等で一貫して使用(schema.ts:186-189,page-tree.ts:4-5)
- 根拠:
[AVOID]Zod スキーマの隣に同じ構造のinterfaceやtypeを手書きで定義すること。型の二重管理は不整合の温床になる- 根拠: コードベース全体で
z.infer以外の型定義が存在しないことで、スキーマ変更時の型更新漏れが構造的に排除されている
- 根拠: コードベース全体で
適用チェックリスト
- [ ] プロジェクトの全 Zod スキーマに対応する型が
z.infer<typeof schema>で導出されているか確認する - [ ] 手書きの型とスキーマが並存している箇所を洗い出し、
z.inferへの一本化を検討する - [ ] React コンポーネントの Props 型が
interfaceで定義されている場合、React.ComponentPropsベースに移行する余地がないか検討する - [ ]
z.lazy()を使っている箇所にz.ZodType<T>の型注釈があるか確認する - [ ] CLI ツールや API エンドポイントの入力バリデーションに Zod スキーマを導入し、型を自動導出する
- [ ] discriminatedUnion を使っている箇所で、バリアント数が多い場合に
.exclude()で集約できないか検討する - [ ] フォームの状態型(値・エラー)がスキーマから導出されているか確認する