[TypeScript] 型チェックをすり抜けるリグレッションをテストで防ぐ

背景

Reactで条件分岐ごとにほぼ同じpropsを繰り返し書いてしまい、共通のオブジェクトへ集約するリファクタリングを行うことはよくある。この作業には見落としやすい罠がある。TypeScriptの型チェックは、値の「形」が期待通りかどうかは検査してくれるが、リファクタリングの過程である属性が意図せず消えたことまでは検知してくれるとは限らない。

特に注意が必要なのは、省略してもコンパイルエラーにならないオプショナルな属性である。値の有無にかかわらず型としては正しいため、集約の過程で書き写し漏れが起きても、型チェッカーは何も教えてくれない。

課題

次のコンポーネントは、variantに応じて見た目のクラス名だけが異なるボタンを描画する。フォーム内に配置されることを想定し、両方の分岐でtype="button"を明示している。

type Props = {
  variant: 'compact' | 'default';
  label: string;
  onClick: () => void;
  disabled?: boolean;
};

// 変更前: 分岐ごとにほぼ同じpropsが重複している
const ActionButton = ({ variant, label, onClick, disabled }: Props) => {
  if (variant === 'compact') {
    return (
      <button type="button" className="btn btn-compact" onClick={onClick} disabled={disabled}>
        {label}
      </button>
    );
  }

  return (
    <button type="button" className="btn btn-default" onClick={onClick} disabled={disabled}>
      {label}
    </button>
  );
};

typeonClickdisabledが両方の分岐で重複しているため、共通のオブジェクトにまとめるリファクタリングを行う。

// 変更後: 重複をcommonPropsへ集約したが`type`を写し忘れた
const ActionButton = ({ variant, label, onClick, disabled }: Props) => {
  const commonProps = {
    className: variant === 'compact' ? 'btn btn-compact' : 'btn btn-default',
    onClick,
    disabled,
  };

  return <button {...commonProps}>{label}</button>;
};

このリファクタリングはtscを通しても、既存のテストを実行しても、何もエラーを出さない。

npx tsc --noEmit
# エラーなし

npx jest ActionButton
# 既存のテストはすべてパス

typeReact.ButtonHTMLAttributes<HTMLButtonElement>の中でオプショナルなフィールドであり、commonPropsの型はオブジェクトリテラルから構造的に推論される。typeキーが存在しなくても{...commonProps}は型的に問題のない式になるため、書き忘れに気付く手段が型チェックの側にはない。

問題が表面化するのは、このボタンが実際にフォームの中に配置されたときである。typeを渡さなかった場合、Reactはtype属性そのものを出力しない。

<!-- typeを渡さなかった場合、type属性自体が出力されない -->
<form>
  <button class="btn btn-compact">キャンセル</button>
</form>

The attribute's missing value default and invalid value default are both the Auto state.

HTML Standard - 4.10.5 The button element

HTML仕様にある通り、type属性を持たない<button>は既定で「Auto」状態になる。Auto状態のボタンは、command/commandfor属性を持たず、かつselect要素の子でない限り、submitとして扱われる。結果として、「キャンセル」ボタンをクリックすると意図せずフォームが送信される。

解決方法

type属性の明示的な指定

まずcommonPropstypeを明示的に含める。

// 修正: `type`をcommonPropsへ明示的に含める
const commonProps = {
  type: 'button' as const,
  className: variant === 'compact' ? 'btn btn-compact' : 'btn btn-default',
  onClick,
  disabled,
};

これでこの1箇所は直る。しかし「オブジェクトへの集約時にオプショナルなキーを書き忘れる」という失敗のパターン自体は、次に同じリファクタリングをする人が再現するまで、型チェックだけでは何度でも起こり得る。

型注釈による必須化

typeキーの省略自体をコンパイルエラーにしたい場合、commonPropsの型を明示的に注釈し、typeを必須にする方法がある。

type RequiredButtonType = Required<Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, 'type'>>;

const commonProps: RequiredButtonType & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'type'> = {
  // `type`を消すとコンパイルエラーになる
  type: 'button',
  className: variant === 'compact' ? 'btn btn-compact' : 'btn btn-default',
  onClick,
  disabled,
};

実際にtypeの行を削ってtscにかけると、次のエラーで検知できる。

npx tsc --noEmit
# error TS2322: Type '{ className: string; onClick: () => void; disabled: boolean | undefined; }'
# is not assignable to type 'Required<Pick<ButtonHTMLAttributes<HTMLButtonElement>, "type">> & ...'.
#   Property 'type' is missing in type '...' but required in type 'Required<Pick<...>>'.

これは有効な対策だが、次にこのコンポーネントを書く人が同じ注釈を思いつき、書き続けてくれることが前提になる。注釈ごと削除されれば、この防御も消える。

スプレッドによる上書き

型を強制しても防げないケースもある。呼び出し側から追加のpropsをスプレッドで受け取る設計にすると、後から展開される値が先の値を上書きする。

// commonPropsの後にrestを展開すると、`type`が上書きされ得る
const ActionButton = ({ variant, label, onClick, disabled, ...rest }: Props & Record<string, unknown>) => {
  const commonProps = {
    type: 'button' as const,
    className: variant === 'compact' ? 'btn btn-compact' : 'btn btn-default',
    onClick,
    disabled,
  };

  // restに`type`が紛れ込んでいても型エラーにはならない
  return (
    <button {...commonProps} {...rest}>
      {label}
    </button>
  );
};

restが緩い型を持つ場合、typeを上書きしてもコンパイルは通る。ここまで来ると、型だけで守り切ることは難しい。

検証

型で防ぎきれない以上、最終的な防御線は実際にレンダリングされたDOMを検証するテストになる。

import { render, screen } from '@testing-library/react';

test('ActionButtonはtype="button"を持ち、フォーム送信をトリガーしない', () => {
  render(<ActionButton variant="compact" label="キャンセル" onClick={() => {}} />);

  const button = screen.getByRole('button', { name: 'キャンセル' });

  // 型チェックでは検知できない、DOM属性そのものを固定する
  expect(button).toHaveAttribute('type', 'button');
});

このテストは、原因が「propsの写し忘れ」であっても「スプレッドの順序による上書き」であっても、結果としてtype属性がbuttonでなくなった時点で失敗する。原因を型で先回りして塞ぐのではなく、観測可能な出力を固定することで、リグレッションの経路を問わずに検知できる点が型による対策との違いである。

同様の考え方は、CSSクラスの付与漏れやARIA属性の欠落など、「オプショナルだが実際には省略してはいけない」属性全般に応用できる。

まとめ

TypeScriptの型チェックは、値の「形」が正しいかどうかを保証するものであり、リファクタリングの過程で値そのものが失われていないかまでは保証しない。特にHTML属性のようにオプショナルな項目は、キーが消えてもコンパイルエラーにならないため、型チェックをすり抜けやすい。

型で防げる範囲は型で防ぎつつ、それでも防ぎきれない観測可能な出力については、実際にレンダリングした結果を検証するテストを最後の砦として用意しておくとよい。

参考