目次
- 概要
- 調査のきっかけ
- 前提知識
- 調査
- 原因考察
- 修正方法
- 教訓
概要
- 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型
名前の通り。T
がany
なら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;
};
原因考察
ここまでは調査で、ようやくここからが本題。
ここでFieldErrorsImpl
と useErrorMessage
の両方を並べて見る。
するととあることに気づくことができた。
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
}
...
}
教訓
型を使えばうまく動くけどジェネリクスだと動かないようなケースは型の絞り込みに注意したい。