🐠

Next.jsの内部実装で使われているカスタムユーティリティ型 - オプショナルプロパティのキーを取得する型に型操作の基本が詰まっている!

に公開

はじめに

Next.jsの内部実装をみていたときに、PickPartialのようなTypeScriptに組み込みで用意されているユーティリティ型ではなく、いくつか独自で定義されたカスタムユーティリティ型が使用されていたのでその型の仕組みや用途について見ていきたいと思います。

みていくNext.jsのバージョンは執筆時点での最新版である15.3.4になりますが、型自体は2020年には追加されていたもので、古めのバージョンのTypeScriptでも動作します。

https://github.com/vercel/next.js/pull/15953

必須プロパティ・オプショナルプロパティのキーだけを抽出する型

Next.jsには、リンクを表すのに<a>タグをよりリッチにした、ソフトナビゲーション(クライアント側のルーティング)やprefetch(ページやデータの事前読み込み)を行うことができる<Link>というコンポーネントが用意されています。

https://nextjs.org/docs/pages/api-reference/components/link

この<Link>コンポーネントはlink.tsxファイルにあり、このファイルではオブジェクトの型を型引数としてとり、そのオブジェクトの型の必須プロパティキーのみを抽出した型を構成したり、逆にそのオブジェクトのオプショナルプロパティキーのみを抽出した型を構成するカスタムのユーティリティ型が定義されています。

オプショナルプロパティとは、{foo?: string}のように?のついたプロパティのことで、オブジェクトにこのプロパティがなくても型エラーとなりません。
逆に、?のついていないプロパティが必須プロパティで、このプロパティを持たないオブジェクトについては型エラーが発生します。

https://zenn.dev/axoloto210/scraps/2caa329b85f519#comment-1c280566552fa7

以下のlink.tsxはPagesRouterで使用されているものです。
AppRouter用のものはpackages/next/src/client/app-dir/link.tsxにあり、そちらでも同じ型が定義されています。

https://github.com/vercel/next.js/blob/v15.3.4/packages/next/src/client/link.tsx#L24-L29

https://github.com/vercel/next.js/blob/v15.3.4/packages/next/src/client/app-dir/link.tsx#L26-L31

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

{}型の変数には、nullundefinedを除く値を代入することができます。
{}はプロパティをもたないオブジェクトの型であり、オプショナルプロパティのみをもつオブジェクトであれば代入可能ですが、必須プロパティを1つでももつと型エラーとなります。

また、never型の変数にはいかなる値も代入することができません。
never型は、never以外の他の型とのユニオン型をとると、never部分はユニオンからなくなります。

たとえばstring | never型はstring型になります。
never型は空集合のようなもので、他の集合との和集合をとっても結果が変わらないわけです。

https://zenn.dev/axoloto210/articles/advent-calender-2023-day10

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;
//      }

https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys

インデックスアクセス型

インデックスアクセス型を使うことで、オブジェクトの型からプロパティの型を抽出することができます。
配列やオブジェクトのインデックスアクセスのようにキーを指定することで、プロパティの型を取得します。

文字列リテラルのユニオン型で指定した場合には、ユニオン型の形でプロパティの型を抽出できます。

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と三項演算子(? :)のような記法を組み合わたものが条件型で、この例では、Tstringの部分型であれば?の後にある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;
};

https://www.typescriptlang.org/docs/handbook/2/mapped-types.html

また、マッピング修飾子を使用することで、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?から?を除く。
}

https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#mapping-modifiers

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に限らず使用されている型です)。
マップ型や条件型といった、型操作を行う上でよく使用される機能の組み合わせで実用的なカスタムユーティリティ型が作成されていました。
これだけ機能が組み合わさっていると、カスタムユーティリティ型として分けられているありがたみを感じられそうですね。

この型の仕組みがわかるようになると、読める・使える型操作の幅が広がりそうです。

GitHubで編集を提案

Discussion