🦾

【TypeScript】指定したパスのみ必須にするUtility Type

2022/02/25に公開

TypeScriptにはプロパティを必須にするRequiredというUtility Typeがある。

type Props = {
  a?: number;
  b?: string;
};

const obj: Required<Props> = { a: 5, b: "test" }; // a, bが必須になる

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

Requiredは全てのプロパティを必須にする。
なのでPropsのaのみ必須にしたい場合は次のようなUtility Typeが必要になる。

type RequiredOnly<T, U extends keyof T> = T & Required<Pick<T, U>>;

const obj2: RequiredOnly<Props, "a"> = { a: 5 }; // aのみ必須でbはオプショナルなまま

Requiredは1階層目のプロパティのみ必須にするので、再帰的に全てのプロパティを必須にしたい場合はまた別のUtility Typeが必要になる。

type DeepRequired<T> = {
  [P in keyof T]-?: DeepRequired<T[P]>;
};

type Props2 = { a?: { b?: string } };

const obj3: DeepRequired<Props2> = { a: { b: "test" } }; // aのみでなくbも必須になる

ではProps2のbのみ必須にしたい場合はどうしよう。
以下のように使えるRequiredPathが欲しい。

type Props3 = {
  a?: { b?: number };
  c?: { d?: string };
};
const obj4: RequirePath<Props2, "a.b" | "c.d"> = {}; // a, cは必須ではないのでOK
const obj5: RequirePath<Props2, "a.b" | "c.d"> = { a: {}, c: {} }; // b, dは必須なのでこれはエラー
const obj6: RequirePath<Props2, "a.b" | "c.d"> = { a: { b: 0 }, c: { d: "test" } }; // OK

前置きが長くなったがこのRequirePathを実装していく。

Step 0: 準備

まず実装にあたって必要なUtility Typeを準備していく。
Step 0では今回の目的とは直接関係ない、ある程度汎用的なUtility Typeのみ扱う。

FilterString

type FilterString<T> = T extends string ? T : never;

FilterStringはstringを拡張した型のみフィルタする。
Conditional Typeの特徴としてUnion Typeが与えられた時には分配される。

type Test = FilterString<"p1" | true | 0>; // = "p1"

https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types

Concat

type Concat<T extends string, U extends string> = `${T}.${U}`;

ConcatはTとUをパスのように結合する。

type Test2 = Concat<"a", "b">; // = "a.b"

LastInUnion

type UnionToIntersection<U> = (U extends unknown ? (arg: U) => 0 : never) extends (arg: infer I) => 0 ? I : never;
type LastInUnion<U> = UnionToIntersection<U extends unknown ? (x: U) => 0 : never> extends (x: infer L) => 0
  ? L
  : never;

LastInUnionはUnion Typeの最後の型を返す。

type Test3 = LastInUnion<"a" | "b" | "c">; // = "c"

UnionToIntersection, LastInUnionについて詳しく知りたい場合は以下を参照。
要約するとどちらも関数のドメインの反変性(関数のUnionがDomainのIntersectionになり、関数のIntersectionはDomainのUnionになる)を利用している。
https://github.com/type-challenges/type-challenges/issues/775
https://github.com/type-challenges/type-challenges/issues/737

Step 1: キーなら必須にする

準備の第2弾としてあるKがTのキーならその値を必須にするRequiredIfKeyを用意する。

type _RequiredIfKey<T, K> = [K] extends [keyof T] ? { [P in K]: NonNullable<T[P]> } : T;
type RequiredIfKey<T, K> = undefined extends T ? _RequiredIfKey<NonNullable<T>, K> : _RequiredIfKey<T, K>;

_RequiredIfKeyは、KがTのキーであった場合はその値をNonNullableにし、そうでない場合はTをそのまま返す、というものである。
[K] extends [keyof T]と[]で包んでいるのはKを分配しないためである。
RequiredIfKeyはTがundefinedと何かのUnionであることを考慮し、その場合はTをNonNullableに包んで、そうでなければそのまま_RequiredIfKeyに渡す。
RequiredIfKeyは以下のように動く。

type Props3 = {
  a?: string | undefined;
  b?: number | undefined;
};

/**
 * type Props4 = {
 *   a: string;
 *   b: number;
 * };
 */
type Props4 = RequiredIfKey<Props3, "a" | "b">;

Step 2: 1つのパスを必須にする

最終的な目的の前に、ここまでで用意した型を用いて1つのパスを必須にできるUtility Typeを作ることができる。

type Recursive<T, Lead extends keyof T, Rest extends string> = {
  [P in Lead]: _RequirePath<T[P], Rest>;
};
type CallRecursive<T, Lead, Rest extends string> = Lead extends keyof T ? Recursive<T, Lead, Rest> : T;
type _RequirePath<T, Path extends string> = Path extends `${infer Lead}.${infer Rest}`
  ? undefined extends T
    ? CallRecursive<NonNullable<T>, Lead, Rest>
    : CallRecursive<T, Lead, Rest>
  : RequiredIfKey<T, Path>;

_RequirePathは単体で使うことは想定しておらず、元の型と&を取る。

type Props5 = {
  a?: { b?: number };
  c?: { d?: string };
};

/**
 * type Props6 = {
 *   a?: {b: number};
 *   c?: {d?: string};
 * };
 */
type Props6 = Props5 & _RequirePath<Props5, "a.b">;

下から順に見ていく。

type _RequirePath<T, Path extends string> = Path extends `${infer Lead}.${infer Rest}`
  ? ...
  : RequiredIfKey<T, Path>;

_RequirePathはTと文字列Pathを渡す。
Pathがピリオド区切りになっていない場合は、この階層で必須にしようとしていると判断しRequiredIfKey<T, Path>する。

...
  ? undefined extends T
    ? CallRecursive<NonNullable<T>, Lead, Rest>
    : CallRecursive<T, Lead, Rest>
  : ...

Pathが${Lead}.${Rest}と分割できる場合、TをNonNullableにしてCallRecursiveに渡す。

type CallRecursive<T, Lead, Rest extends string> = Lead extends keyof T ? Recursive<T, Lead, Rest> : T;

CallRecursiveではLeadがTのキーであることだけチェックしてRecursiveを呼ぶ。
キーでない場合はそのままTを返す。

type Recursive<T, Lead extends keyof T, Rest extends string> = {
  [P in Lead]: _RequirePath<T[P], Rest>;
};

RecursiveはLead中のPについて値T[P]中のパスRestを再帰的に辿っていく。
最終的に_RequirePathのRequiredIfKeyに到達し指定したパスのみ必須にする。

CallRecursiveとRecursiveを分けたのは、RecursiveにおいてLeadのベース制約をextends keyof Tとすることで、RecursiveがModifiers Typeとなり?を継承できるようになるからである。
https://zenn.dev/qnighy/articles/dde3d980b5e386#修飾子型を含む形

なお_RequirePathは1つのパスしか想定していないため、[P in Lead]のPはLeadのみの想定である。

Step 3: パスの列挙

_RequirePathはパスとして文字列で受け付けていたが、パスも型として取れるようにしたい。
Step 3ではパスを列挙するUtility Typeを用意する。

type RecursivePaths<T, K extends keyof T> = Concat<FilterString<K>, Paths<T[K]>>;
type ForEachKey<T, K extends keyof T> = K extends K ? RecursivePaths<T, K> : never;
type Paths<T> = T extends object ? FilterString<keyof T> | ForEachKey<T, keyof T> : never;

下から順に見ていく。

PathsはまずTがobjectでない時はneverを返す。
これは後述の再帰の結果プリミティブが渡ってくることを想定している。
Tがオブジェクトの場合はまずFilterString<keyof T>で1階層目のキーを列挙する。

ForEachKeyはK extends Kという恒真のConditional Typeを使ってPathsからkeyof Tで渡ってきたUnionをRecursiveに分配する。

Recursiveは今の階層のキーと再帰的な結果をパスの形で結合する。

Pathsは以下のように動く。

type Props7 = {
  a?: { b?: number };
  c?: { d?: string };
};

type Test4 = Paths<Props7>; // = "a" | "a.b" | "c" | "c.d"

Step 4: 指定した全てのパスを必須にする

ようやく最後である。
RequirePathは次のようになる。

type RequirePath<T, Path extends Paths<T>, Last = LastInUnion<Path>> = [Path] extends [never]
  ? T
  : Last extends string
  ? _RequirePath<T, Last> & RequirePath<T, Exclude<Path, Last>>
  : never;

少しずつ見ていこう

type RequirePath<T, Path extends Paths<T>, Last = LastInUnion<Path>> = ...

RequirePathは何かを必須にしたいTとパスのUnionであるPathを渡す。
そしてそのUnionの最後を計算し、Lastに入れておく。

... = [Path] extends [never]
  ? T
  : ...

Pathは後述のRequirePath<T, Exclude<Path, Last>>の再帰によって最終的にneverになる。
neverの場合はTそのものを返す。
ここでもPath, neverの両方を[]で包んでいるのはUnionを分配させないためである。

  ...
  : Last extends string
  ? ...
  : never;

_RequirePathで第2型パラメータに文字列を要求しているので分岐をしているが、これは必ず真になる。

  ...
  ? _RequirePath<T, Last> & RequirePath<T, Exclude<Path, Last>>
  ...

ここがRequirePathの本体である。
_RequirePath<T, Last>でT中の1つのパスLastのみを必須にし、残りは再帰的に計算する。
既に見たように再帰のベースケースではTそのものが返るので、最終的にPathで指定したTのパスのみが必須になった型が返る。

サンプル

type Props8 = {
  a?: { b?: number };
  c?: { d?: string };
};
/**
 * type Props9 = {
 *   a?: { b: number };
 *   c?: { d: string };
 * };
 */
type Props9 = RequirePath<Props8, "a.b" | "c.d">;

type Props10 = { a?: { b: { c?: { d?: string } } } };

// type Props11 = { a?: { b: { c: { d: string } } } };
type Props11 = RequirePath<Props10, "a.b.c" | "a.b.c.d">;

本記事のまとめはこちら。
https://gist.github.com/YunosukeY/f3b317460fe5787c3c54fe788faf0ec7

最後に

まずはここまで読んでくださったことに感謝する。
本記事が分かりづらかったならそれは説明が雑だったからだろう。

本記事の実装はコーナーケースを考えていないので「こういう場合が考えられていないのではないか」などあればぜひ教えていただきたい。
用語や概念の勘違いなども同様。

TypeScriptの型システムはほぼチューリング完全でいろいろなことができる。
足し算や数独をやっている方もいるので、気になったら調べてみて欲しい。

参考

調べつつ書いていたので忘れてしまったものもある。

こちらの記事は日本語でMapped Typeについて解説していてとてもありがたかった。
https://zenn.dev/qnighy/articles/dde3d980b5e386

こちらのリポジトリはTypeScriptの型レベルプログラミングのノウハウが詰まっている。
他人の答えも見れてとても勉強になった。
https://github.com/type-challenges/type-challenges

Discussion