Share
Embed
「型ガードしたのにnullable」から卒業する
Hayato Yokoyama
June 18, 2026
Transcript
43 slides
-
横⼭ 隼 Yokoyama Hayato • アルバイト求⼈サービス「バイトル」の エンジニア • TypeScript,React Routerで
バイトルのリニューアルしています • TSKaigiでは 弊社のスポンサーセッションをしました • dipの「d」をやったつもりが「b」だった -
Copyright © DIP Corporation, All rights reserved. 読み込み中‧エラー‧データを表すシンプルなカードUI type JobCardProps
= { isLoading: boolean; error: Error | null; job: JobData | null; }; -
Copyright © DIP Corporation, All rights reserved. 読み込み中‧エラー‧データを表すシンプルなカードUI type JobCardProps
= { isLoading: boolean; error: Error | null; job: JobData | null; }; • ⾮同期でデータ取ってきて表⽰するコンポーネント全般でよくある • useQuery (TanStack Query) や useSWR の戻り値もこんな感じ → { isLoading, error, data } = useQuery(...); -
Copyright © DIP Corporation, All rights reserved. ロード中なのに、求⼈データがある <JobCard isLoading={true}
error={null} job={jobData} /> -
Copyright © DIP Corporation, All rights reserved. ロード中なのに、求⼈データがある <JobCard isLoading={true}
error={null} job={jobData} /> -
Copyright © DIP Corporation, All rights reserved. ロード中なのに、求⼈データがある <JobCard isLoading={true}
error={null} job={jobData} /> エラーなのに、求⼈データがある <JobCard isLoading={false} error={new Error()} job={jobData} /> -
Copyright © DIP Corporation, All rights reserved. ロード中なのに、求⼈データがある <JobCard isLoading={true}
error={null} job={jobData} /> エラーなのに、求⼈データがある <JobCard isLoading={false} error={new Error()} job={jobData} /> -
Copyright © DIP Corporation, All rights reserved. ロード中なのに、求⼈データがある <JobCard isLoading={true}
error={null} job={jobData} /> エラーなのに、求⼈データがある <JobCard isLoading={false} error={new Error()} job={jobData} /> TypeScriptはどちらも型エラーにしてくれない -
Copyright © DIP Corporation, All rights reserved. function JobCard({ isLoading,
error, job }: JobCardProps) { if (isLoading) return <Spinner />; if (error) return <ErrorMessage err={error} />; return ( <div> <h2>{job?.title ?? ""}</h2> <p>{job!.companyName}</p> </div> ); } こんな<JobCard>を書いてしまうとどうなるか? -
Copyright © DIP Corporation, All rights reserved. function JobCard({ isLoading,
error, job }: JobCardProps) { if (isLoading) return <Spinner />; if (error) return <ErrorMessage err={error} />; return ( <div> <h2>{job?.title ?? ""}</h2> <p>{job!.companyName}</p> </div> ); } こんな<JobCard>を書いてしまうとどうなるか? isLoading: falseもerror: nullも 確認したのに -
Copyright © DIP Corporation, All rights reserved. function JobCard({ isLoading,
error, job }: JobCardProps) { if (isLoading) return <Spinner />; if (error) return <ErrorMessage err={error} />; return ( <div> <h2>{job?.title ?? ""}</h2> <p>{job!.companyName}</p> </div> ); } こんな<JobCard>を書いてしまうとどうなるか? isLoading: falseもerror: nullも 確認したのに TypeScriptは jobがまだnullableだと思ってる -
Copyright © DIP Corporation, All rights reserved. function JobCard({ isLoading,
error, job }: JobCardProps) { if (isLoading) return <Spinner />; if (error) return <ErrorMessage err={error} />; return ( <div> <h2>{job?.title ?? ""}</h2> <p>{job!.companyName}</p> </div> ); } こんな<JobCard>を書いてしまうとどうなるか? isLoading: falseもerror: nullも 確認したのに TypeScriptは jobがまだnullableだと思ってる ➡ 型ガードしたはずなのに、jobには効かず ?? や ! が散らばる -
Copyright © DIP Corporation, All rights reserved. みんな⼤好き、Buttonコンポーネント type ButtonProps
= { href: string | null; onClick: (() => void) | null; children: ReactNode; }; 同じボタンの⾒た⽬で 「ボタン」として動作するときと「リンク」として動作するときがある -
Copyright © DIP Corporation, All rights reserved. これもありえない<Button>が書けてしまう hrefもあるし、onClickもある (※仕様によってはこれが正しい場合もあるけど)
<Button href="/about" onClick={() => alert("hoge!")}>クリック</Button> -
Copyright © DIP Corporation, All rights reserved. これもありえない<Button>が書けてしまう hrefもあるし、onClickもある (※仕様によってはこれが正しい場合もあるけど)
<Button href="/about" onClick={() => alert("hoge!")}>クリック</Button> -
Copyright © DIP Corporation, All rights reserved. これもありえない<Button>が書けてしまう hrefもあるし、onClickもある (※仕様によってはこれが正しい場合もあるけど)
<Button href="/about" onClick={() => alert("hoge!")}>クリック</Button> 画面遷移もしないし、クリックイベントも発生しない <Button href={null} onClick={null}>クリック</Button> -
Copyright © DIP Corporation, All rights reserved. これもありえない<Button>が書けてしまう hrefもあるし、onClickもある (※仕様によってはこれが正しい場合もあるけど)
<Button href="/about" onClick={() => alert("hoge!")}>クリック</Button> 画面遷移もしないし、クリックイベントも発生しない <Button href={null} onClick={null}>クリック</Button> -
Copyright © DIP Corporation, All rights reserved. こんな<Button>を書いてしまうとどうなるか? function Button({
href, onClick, children }: ButtonProps) { if (!href && !onClick) return null; if (href && onClick) return ...; if (href) return <a href={href}>{children}</a>; return <button onClick={onClick!}>{children}</button>; } -
Copyright © DIP Corporation, All rights reserved. こんな<Button>を書いてしまうとどうなるか? function Button({
href, onClick, children }: ButtonProps) { if (!href && !onClick) return null; if (href && onClick) return ...; if (href) return <a href={href}>{children}</a>; return <button onClick={onClick!}>{children}</button>; } リンクもクリックイベントもない → 何もしないボタン? -
Copyright © DIP Corporation, All rights reserved. こんな<Button>を書いてしまうとどうなるか? function Button({
href, onClick, children }: ButtonProps) { if (!href && !onClick) return null; if (href && onClick) return ...; if (href) return <a href={href}>{children}</a>; return <button onClick={onClick!}>{children}</button>; } リンクもクリックイベントもない → 何もしないボタン? リンクもクリックイベントも両⽅ある → どっちを優先する? -
Copyright © DIP Corporation, All rights reserved. こんな<Button>を書いてしまうとどうなるか? ➡ 仕様ではなく型の⽳埋めのためのif⽂が⽣まれる
function Button({ href, onClick, children }: ButtonProps) { if (!href && !onClick) return null; if (href && onClick) return ...; if (href) return <a href={href}>{children}</a>; return <button onClick={onClick!}>{children}</button>; } リンクもクリックイベントもない → 何もしないボタン? リンクもクリックイベントも両⽅ある → どっちを優先する? -
Copyright © DIP Corporation, All rights reserved. JobCardとButton、根本原因は同じ • 実際に存在し得る状態以上に型が表現出来てしまっている
• JobCardPropsは boolean × (Error | null) × (JobData | null) = 2×2×2 = 8通り書けていた • でも実際にありえるのは loading / error / success の3通りだけ -
Copyright © DIP Corporation, All rights reserved. Make Impossible States
Impossible 「不可能な状態が起こらないようにインターフェイスを設計しよう」という設計思想 -
Copyright © DIP Corporation, All rights reserved. 解法① Discriminated Union
type JobCardProps = | { status: "loading" } | { status: "error"; error: Error } | { status: "success"; job: JobData }; 1. 状態ごとに型を分ける (ここではloading, error, success) 2. 判別するための共通フィールドを持たせる (ここではstatus) ※ サバイバルTypeScriptがとても参考になります -
Copyright © DIP Corporation, All rights reserved. ⽐較してみると... ⾮Discriminated Union
type JobCardProps = { isLoading: boolean; error: Error | null; job: JobData | null; }; Discriminated Union type JobCardProps = | { status: "loading" } | { status: "error"; error: Error } | { status: "success"; job: JobData }; 8通り(うち5通りはありえない) 3通り(ありえる状態と⼀致) -
Copyright © DIP Corporation, All rights reserved. Discriminated Union化すると、ありえない <JobCard>
が書けなくなる <JobCard status="loading" job={jobData} /> // ^^^^^^^^^^^^ // Error: Property 'job' does not exist on type '{ status: "loading" }' ロード中なのに、求⼈データを渡そうとすると... エラーなのに、求⼈データを渡そうとすると... <JobCard status="error" error={err} job={jobData} /> // ^^^^^^^^^^^^ // Error: Property 'job' does not exist on type '{ status: "error"; error: Error }' -
Copyright © DIP Corporation, All rights reserved. Discriminated Union化すると、ありえない <JobCard>
が書けなくなる <JobCard status="loading" job={jobData} /> // ^^^^^^^^^^^^ // Error: Property 'job' does not exist on type '{ status: "loading" }' ロード中なのに、求⼈データを渡そうとすると... エラーなのに、求⼈データを渡そうとすると... <JobCard status="error" error={err} job={jobData} /> // ^^^^^^^^^^^^ // Error: Property 'job' does not exist on type '{ status: "error"; error: Error }' ➡ TypeScript が「ありえない型」を全部弾いてくれる -
Copyright © DIP Corporation, All rights reserved. <JobCard>から ! や
?? が消える function JobCard(props: JobCardProps) { if (props.status === "loading") return <Spinner />; if (props.status === "error") return <Error err={props.error} />; // ここで props.status === "success" が確定 return ( <div> <h2>{props.job.title}</h2> <p>{props.job.companyName}</p> <p>時給 {props.job.hourlyWage.toLocaleString()}円</p> </div> ); } -
Copyright © DIP Corporation, All rights reserved. <JobCard>から ! や
?? が消える function JobCard(props: JobCardProps) { if (props.status === "loading") return <Spinner />; if (props.status === "error") return <Error err={props.error} />; // ここで props.status === "success" が確定 return ( <div> <h2>{props.job.title}</h2> <p>{props.job.companyName}</p> <p>時給 {props.job.hourlyWage.toLocaleString()}円</p> </div> ); } Jobが確実に存在するので、 ! や ?? が消える -
Copyright © DIP Corporation, All rights reserved. 他にもバイトルでの使われ⽅ • 同じパスで「掲載中」と「掲載終了」を表現する仕事詳細ページ
• React Routerのloaderで求⼈情報を取得する処理 export async function loader({ params }) { const result = await fetchJobDetail(jobId); if (result.kind === "expired") { return data({ kind: "expired" as const, ... }, { status: 404 }); } return data({ kind: "active" as const, jobId, jobTitle, ... }); } -
Copyright © DIP Corporation, All rights reserved. 他にもバイトルでの使われ⽅ • 同じパスで「掲載中」と「掲載終了」を表現する仕事詳細ページ
• React Routerのloaderで求⼈情報を取得する処理 export async function loader({ params }) { const result = await fetchJobDetail(jobId); if (result.kind === "expired") { return data({ kind: "expired" as const, ... }, { status: 404 }); } return data({ kind: "active" as const, jobId, jobTitle, ... }); } as const で判別子をリテラル型に固めて返す -
Copyright © DIP Corporation, All rights reserved. 解法② オプショナルnever型の排他的論理和(XOR) •
どちらか⽚⽅しか持てない を型で表現 • 持たない⽅のフィールドに ?: never を付ける • Discriminated Unionのような判別⼦は不要 type ButtonProps = | { href: string; onClick?: never; children: ReactNode } | { href?: never; onClick: () => void; children: ReactNode }; -
Copyright © DIP Corporation, All rights reserved. ⽐較してみると... ⾮XOR XOR
(?: never) type ButtonProps = { href: string | null; onClick: (() => void) | null; children: ReactNode; }; type ButtonProps = | { href: string; onClick?: never; children: ReactNode } | { href?: never; onClick: () => void; children: ReactNode }; -
Copyright © DIP Corporation, All rights reserved. XOR化すると、ありえない <Button> が書けなくなる
href と onClick を両⽅渡そうとすると... href も onClick もない場合 <Button href="/about" onClick={() => alert("!")}>クリック</Button> // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // Error: Type '() => void' is not assignable to type 'undefined' <Button>クリック</Button> // Error: Property 'href' or 'onClick' is required -
Copyright © DIP Corporation, All rights reserved. XOR化すると、ありえない <Button> が書けなくなる
href と onClick を両⽅渡そうとすると... href も onClick もない場合 ➡ TypeScript が「ありえない型」を全部弾いてくれる <Button href="/about" onClick={() => alert("!")}>クリック</Button> // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // Error: Type '() => void' is not assignable to type 'undefined' <Button>クリック</Button> // Error: Property 'href' or 'onClick' is required -
Copyright © DIP Corporation, All rights reserved. まとめ • Make
Impossible States Impossible で不可能な状態が起こらないように インターフェイスを設計しよう • ! や ?? が増えてきたら、⾒直しの予兆 • Discriminated UnionやXORで、不可能な状態を書けなくしよう