Next.jsの内部実装で使われているカスタムユーティリティ型 - オプショナルプロパティのキーを取得する型に型操作の基本が詰まっている!
はじめに
Next.jsの内部実装をみていたときに、Pick
やPartial
のようなTypeScriptに組み込みで用意されているユーティリティ型ではなく、いくつか独自で定義されたカスタムユーティリティ型が使用されていたのでその型の仕組みや用途について見ていきたいと思います。
みていくNext.jsのバージョンは執筆時点での最新版である15.3.4
になりますが、型自体は2020年には追加されていたもので、古めのバージョンのTypeScriptでも動作します。
必須プロパティ・オプショナルプロパティのキーだけを抽出する型
Next.jsには、リンクを表すのに<a>
タグをよりリッチにした、ソフトナビゲーション(クライアント側のルーティング)やprefetch
(ページやデータの事前読み込み)を行うことができる<Link>
というコンポーネントが用意されています。
この<Link>
コンポーネントはlink.tsx
ファイルにあり、このファイルではオブジェクトの型を型引数としてとり、そのオブジェクトの型の必須プロパティキーのみを抽出した型を構成したり、逆にそのオブジェクトのオプショナルプロパティキーのみを抽出した型を構成するカスタムのユーティリティ型が定義されています。
オプショナルプロパティとは、{foo?: string}
のように?
のついたプロパティのことで、オブジェクトにこのプロパティがなくても型エラーとなりません。
逆に、?
のついていないプロパティが必須プロパティで、このプロパティを持たないオブジェクトについては型エラーが発生します。
以下のlink.tsx
はPagesRouterで使用されているものです。
AppRouter用のものはpackages/next/src/client/app-dir/link.tsx
にあり、そちらでも同じ型が定義されています。
RequiredKeys
型
RequiredKeys
型は、オブジェクトの型から必須プロパティキーのみを抜き出した型を作成するカスタムユーティリティ型です。
オブジェクトの型T
を渡すと、T
の必須プロパティキーの文字列リテラルのユニオン型が得られます。
type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K
}[keyof T]
RequiredKeys
型の型引数に「必須プロパティやオプショナルプロパティを複数もつオブジェクトの型」を渡し、どのような型が得られるかをみてみます。
以下のオブジェクトの型Obj
には、3つの必須プロパティと3つのオプショナルプロパティが含まれています。
requiredUndefinedOrNumber: number | undefined;
のように、undefined
とのユニオン型であったとしても?
がついていないプロパティは必須プロパティです。
type Obj = {
requiredString: string;
requiredObject: {
required: string;
optional?: string;
};
optionalString?: string;
optionalObject?: {
required: string;
optional?: string;
};
requiredUndefinedOrNumber: number | undefined;
optionalUndefinedOrNumber?: number | undefined;
};
このObj
型をRequiredKeys
の型引数に渡してみると、確かに必須プロパティキーのユニオン型となっています。
type Test = RequiredKeys<Obj>;
// ^? type Test = "requiredString" | "requiredObject" | "requiredUndefinedOrNumber"
OptionalKeys
型
オプショナルプロパティのキーのみを抽出する型で、RequiredKeys
型とは条件型の分岐部分が異なっています。
type OptionalKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never
}[keyof T]
Obj
型を渡してみると、オプショナルプロパティのキーのみが取得できていることがわかります。
type Test2 = OptionalKeys<Obj>;
// ^? type Test2 = "optionalString" | "optionalObject" | "optionalUndefinedOrNumber"
RequiredKeys
の構成要素
RequiredKeys
型とOptionalKeys
型は条件型の分岐部分のみが異なり、基本的な構成は同じため、RequiredKeys
がどのように構成されているかを見ていきたいと思います。
RequiredKeys
型は以下を組み合わせて構成されています。
-
{}
型とnever
型 -
Pick
型(ユーティリティ型) - インデックスアクセス型
- 条件型(Conditional Types)
- マップ型(Mapped Types)とマッピング修飾子(mapping modifier)
{}
型とnever
型
{}
型の変数には、null
とundefined
を除く値を代入することができます。
{}
はプロパティをもたないオブジェクトの型であり、オプショナルプロパティのみをもつオブジェクトであれば代入可能ですが、必須プロパティを1つでももつと型エラーとなります。
また、never
型の変数にはいかなる値も代入することができません。
never
型は、never
以外の他の型とのユニオン型をとると、never
部分はユニオンからなくなります。
たとえばstring | never
型はstring
型になります。
never
型は空集合のようなもので、他の集合との和集合をとっても結果が変わらないわけです。
Pick
型
Pick
型はPick<Type, Keys>
のようにオブジェクトの型Type
とオブジェクトのプロパティキーの文字列リテラル型のユニオン型Keys
を指定することで、指定したプロパティキーをもつオブジェクトの型を作成できます。
type PickedBaseObj = {
pickedString: string
pickedNumber: number
other: string
}
type PickedObj = Pick<PickedBaseObj, 'pickedString'|'pickedNumber'>
// ^? type PickedObj = {
// pickedString: string;
// pickedNumber: number;
// }
インデックスアクセス型
インデックスアクセス型を使うことで、オブジェクトの型からプロパティの型を抽出することができます。
配列やオブジェクトのインデックスアクセスのようにキーを指定することで、プロパティの型を取得します。
文字列リテラルのユニオン型で指定した場合には、ユニオン型の形でプロパティの型を抽出できます。
type IndexedAccessBaseObj = {
indexedString: string
indexedNumber: number
other: number
}
type IndexedAccessObj = IndexedAccessBaseObj['indexedString' | 'indexedNumber']
// ^? type IndexedAccessObj = string | number
条件型(Conditional Types)
T extends string ? number : boolean
のように、extends
と三項演算子(? :
)のような記法を組み合わたものが条件型で、この例では、T
がstring
の部分型であれば?
の後にあるnumber
型、部分型でなければ:
の後にあるstring
型となります。
type Conditional<T> = T extends string ? number : boolean
type ConditionalString = Conditional<string>
// ^? type ConditionalString = number
type ConditionalNumber = Conditional<number>
// ^? type ConditionalNumber = boolean
マップ型(Mapped Types)とマッピング修飾子(mapping modifier)
マップ型を使用することで、オブジェクトの型から別のオブジェクトの型を作成することができます。
オブジェクトのプロパティキーを別のオブジェクトの型へとマッピングできるわけです。
例えば、以下の例ではType
として渡したオブジェクトの型からは、プロパティがすべてboolean
型のオブジェクトの型が得られます。
type OptionsFlags<Type> = {
[Property in keyof Type]: boolean;
};
また、マッピング修飾子を使用することで、readonly
や?
(オプショナルプロパティかどうか)をつけたり外したりすることができます。
-?
を指定することで、必須プロパティにすることができます。
type MappedBaseType = {
foo?: string;
bar?: number;
};
type RequiredMappedType = {
[K in keyof MappedBaseType]-?: MappedBaseType[K];
};
// type RequiredMappedType = {
// foo: string;
// bar: number;
// }
keyof MappedBaseType
は"foo" | "bar"
となります。
この"foo"
と"bar"
それぞれに対して、修飾子のつけ外しやプロパティの型の指定を行っていくイメージです。
{
"foo": MappedBaseType["foo"] // foo?から?を除く。
"bar": MappedBaseType["bar"] // bar?から?を除く。
}
RequiredKeys
の仕組み
再度RequiredKeys
の構造を見てみます。
type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K
}[keyof T]
RequiredKeys
はマップ型{ [K in keyof T]-?: {} extends Pick<T, K> ? never : K }
でオブジェクトの型を作成し、インデックスアクセス[keyof T]
によってプロパティの型のユニオン型を取得しています。
マップ型の部分について詳しくみてみます。
[K in keyof T]-?
の部分では、オブジェクトの型T
から新しくオブジェクトの型を作る際に、オプショナルプロパティをすべて必須プロパティにしています。
条件型{} extends Pick<T, K> ? never : K
の部分をみてみます。
Pick<T,K>
は、プロパティキーK
によってT
のプロパティの型を抽出しています。
この{}
がPick<T,K>
の部分型であれば、マップ型のプロパティキーK
に対応するプロパティの型はnever
型となり、そうでなければプロパティキーK
になります。
{}
がPick<T,K>
の部分型であるとは、{}
型の値がPick<T,K>
の変数に代入できることです。
K
がオプショナルプロパティのキーのときには、Pick<T,K>
は{foo?: number}
のように?
がついたプロパティを1つもつオブジェクトの型となります。
{}
はプロパティをもたないオブジェクトの型ですので、オプショナルプロパティのみをもつオブジェクトであれば代入できます。
{} extends Pick<T, K> ? never : K
によって、T
の必須プロパティキーに対しては値としてプロパティキーをもち、T
のオプショナルプロパティキーに対しては値の型としてnever
をもつことになります。
マップ型で作成されるオブジェクトの型は以下のコメントアウトの箇所のような形になります。
type RequiredObj<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K;
};
type MappedObj = RequiredObj<Obj>;
// type MappedObj = {
// requiredString: "requiredString";
// requiredObject: "requiredObject";
// optionalString: never;
// optionalObject: never;
// requiredUndefinedOrNumber: "requiredUndefinedOrNumber";
// optionalUndefinedOrNumber: never;
// }
-?
が指定されていない場合には、never
の部分がundefined
となってしまい、[keyof T]
でインデックスアクセス型を取得すると、ユニオン型にundefined
が含まれてしまうわけです。
(never
とのユニオン型であれば、never
はユニオン型から消えてくれるのでした。)
このマップ型にT
のプロパティキーのユニオン型keyof T
によるインデックスアクセス型を取得すると、必須プロパティのキーとnever
のユニオン型が得られます。
never
はユニオンをとると消えますので、最終的に必須プロパティのキーが取得できるわけです。
さいごに
Next.jsで使用されているカスタムユーティリティ型をみていきました(Next.jsに限らず使用されている型です)。
マップ型や条件型といった、型操作を行う上でよく使用される機能の組み合わせで実用的なカスタムユーティリティ型が作成されていました。
これだけ機能が組み合わさっていると、カスタムユーティリティ型として分けられているありがたみを感じられそうですね。
この型の仕組みがわかるようになると、読める・使える型操作の幅が広がりそうです。
Discussion