1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React Hook FormのFieldErrorsの型を掘り下げる【自由研究】

Last updated at Posted at 2025-01-11

目次

  • 概要
  • 調査のきっかけ
  • 前提知識
  • 調査
  • 原因考察
  • 修正方法
  • 教訓

概要

  • React Hook Formの型を掘り下げてみた自由研究記事
  • useFormState と ジェネリクス を組み合わせたときの型エラーが起きた
  • なんてことはなくただのType Narrowingの問題だった

※執筆時点でのバージョン (7.54.2)を利用
※ただの自由研究なのであまり有益な情報はないかもしれません

調査のきっかけ

React Hook Formの useFormStateから 取得できる errors で、うまく型が当たらないケースに遭遇したため。

FormStateからエラーを取り出すためのカスタムフックを作ろうとした。
ここで、以下のようにスキーマが確定していればTypeScriptからは怒られない。

type TodoFormSchema = { items: Array<string> }; // 配列を含むスキーマなのがポイント
function useTodoErrorMessage(key: keyof TodoFormSchema) {
  const { errors } = useFormState<TodoFormSchema>();
  const error = errors[key];
  // 配列として認識されているらしく、index Accessできる
  console.log(error?.[0]?.message); // OK
  console.log(error?.[1]?.message); // OK
  ...
}

なお、フォームスキーマをジェネリクスにして汎用化するとうまくいかない。

function useErrorMessage<T extends FieldValues>(key: keyof T) {
  const { errors } = useFormState<T>();
  const error = errors[key];
  // 配列にはなっておらずindexでアクセスできない
  console.log(error?.[0]?.message); // OK
  console.log(error?.[1]?.message); // OK
 ...
}

気になったので調べてみることにした。

前提知識

Conditonal Types

条件分岐を使って型を定義できる。例えば以下のような場合 true型になる。

補足:このようケースの場合、true型のまま使うというよりは、この結果を使ってさらに別のConditional Typeを作ることのほうが多いように思う。

// IsString<"A"> = true
// IsString<1> = false
type IsString<T> = T extends string ? true : false;

keyof 演算子

keyofとinを組みあわせて利用すると、オブジェクトや配列を一括置換したりできる。

// ToString<{ a: number; b: boolean }> = { a: string; b: string; }
// ToString<[number, boolean]> = [string, string]
type ToString<T> = { [K in keyof T]: string }

調査

React Hook Formで定義されている型を順番に紐解いていく。

IsAny型

名前の通り。Tanyならtrue型だし、stringなどの場合はfalse型になる。

// IsAny<any> = true
// IsAny<string> = false
export type IsAny<T> = 0 extends 1 & T ? true : false;

DeepRequired型

これはよくみるやつで、オブジェクトに対し再帰的にnullableを外していくもの。

// DeepRequired<{ a?: { b?: string } }> = { a: { b: string } }
export type DeepRequired<T> = T extends BrowserNativeObject | Blob
  ? T
  : {
      [K in keyof T]-?: NonNullable<DeepRequired<T[K]>>;
    };

FieldValues型

なんてことはないただの Record。第二引数が anyなので object in objectな入れ子構造にも対応しているし、配列でも構わない。

export type FieldValues = Record<string, any>;

FieldErrors型

FieldErrorsImplのラッパーと思われるもの。FieldValues extends IsAny<FieldValues>は常にfalseだし、rootは今回あまり気にしなくて良いので、FieldErros<T> = FieldErrorsImpl<T>と考えてしまって良さそう。

export type FieldErrors<T extends FieldValues = FieldValues> = Partial<
  FieldValues extends IsAny<FieldValues>
    ? any
    : FieldErrorsImpl<DeepRequired<T>>
> & {
  root?: Record<string, GlobalError> & GlobalError;
};

FieldErrorsImpl型

FieldErrorsの実体。渡されたFieldValues (中身はRecord)を見て、それぞれのkeyに対して適当なobjectを設定しているように見える。

export type FieldErrorsImpl<T extends FieldValues = FieldValues> = {
  [K in keyof T]?: T[K] extends BrowserNativeObject | Blob
    ? FieldError
    : K extends "root" | `root.${string}`
    ? GlobalError
    : T[K] extends object
    ? Merge<FieldError, FieldErrorsImpl<T[K]>>
    : FieldError;
};

原因考察

ここまでは調査で、ようやくここからが本題。
ここでFieldErrorsImpluseErrorMessage の両方を並べて見る。
するととあることに気づくことができた。

export type FieldErrorsImpl<T extends FieldValues = FieldValues> = {
  [K in keyof T]?: T[K] extends BrowserNativeObject | Blob
    ? FieldError
    : K extends "root" | `root.${string}`
    ? GlobalError
    : T[K] extends object
    ? Merge<FieldError, FieldErrorsImpl<T[K]>>
    : FieldError;
};
// MEMO: FieldValuesは Record<string, any>
function useErrorMessage<T extends FieldValues>(key: keyof T) {
  // MEMO errorsの型は FieldErrorsM<T> = FieldErrorsImpl<T>
  const { errors } = useFormState<T>();
  // FieldError | GlobalError | Merge<FieldError, FieldErrorsImpl<T[K]>>
  // のどれかになっていそう
  const error = errors[key];

T extends FieldValues (=T extends Record<string, any>) のままでは、

  • FieldError
  • GlobalError
  • Merge<FieldError, FieldErrorsImpl<T[K]>>

のどれなのかが決定できないからうまく型を推論してくれないのである。

そのため、以下のどちらかで対応すれば良さそうである。

  • T に正しい制約をつける
  • 関数内でType Narrowingを行う

修正方法

今回は関数内でType Narrowingする方法で修正した。

function useErrorMessage<T extends FieldValues>(key: keyof T) {
  const { errors } = useFormState<T>();
  
  // この段階では絞り込めていない
  // FieldError | GlobalError | Merge<FieldError, FieldErrorsImpl<T[K]>>
  const error = errors[key];

  // 
  // Merge<FieldError, FieldErrorsImpl<T[K]>>の時
  // 以下のようなerrorを想定
  // [{ message: "1st error" }, { message: "2nd error" }]
  //
  if(Array.isArray(error)) {
    if("message" in error[0]) {
      const message = error[0].message
      console.log(message)
    }
    ...
    return
  }
  
  ...
}

教訓

型を使えばうまく動くけどジェネリクスだと動かないようなケースは型の絞り込みに注意したい。

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?