導入
今日の話題はTypeScriptで型定義を再利用してインターフェースの一部の任意プロパティを必須プロパティに変更したりする方法についてです。この逆の一部の必須プロパティを任意プロパティに変更するのはここで紹介する方法の応用なので省略します。
※ 私が株式会社愛宕 Advent Calendar 2023に書く記事は主に社内向けに共有しておきたいけど勉強会をするまでもないちょっとしたTipsにしたいと思います。
ここで、必須プロパティとは次の例のように定義されたインターフェース内のnameやtypeで、任意プロパティとはageやhomelandを指します。
interface Player {
name: string
type: string
age?: number
homeland?: string
}
TypeScriptではユーティリティ型と呼ばれる既存の型から別の型を生み出す型が存在します。型から型を生み出すということは型を変換するともいえ、また型を導き出す関数とみなすこともできます。
TypeScriptのビルトインパッケージで定義されていてimportなしに利用できるユーティリティ型として、全てのプロパティを必須にするRequired<T>があります。全てのプロパティを任意にするのはPartial<T>です。
<T>のTの部分には既存の型が入ります。つまり、
type PlayerAllPropertyRequired = Required<Player>
type PlayerAllPropertyOptional = Partial<Player>
と書けば新しく定義されたPlayerAllPropertyRequiredのageとhomelandは?が取れて必須となり、PlayerAllPropertyOptionalのnameとtypeは?がついて任意となります。
ここまでは簡単です。問題はageだけ必須にしたり、nameだけ任意にしたりする方法が意外と難しいことです。
解決
あるインターフェースの一部のプロパティを必須にするには、次のようなユーティリティ型を書くといいでしょう。
type RequiredProperties<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
使い方は、
type PlayerSomePropertyRequired = RequiredProperties<Player, "name" | "type" | "age">
となります。この例の場合、nameとtypeは元々必須なので書いても書かなくても同じ結果となります。
解説
どういう仕組みなのか解説します。
まず、Kの後についているextends keyof Tという部分がわかりにくいですが、これはKが満たすべき制約を示しています。
ユーティリティ型を型の関数だとみなすとKが(型)変数でkeyof Tが(型)変数Kの型と言ってもいいです。
keyof TはTのプロパティをリテラル型として定義し全て結合してユニオン型としたものです。
例えば、keyof Playerは"name" | "type" | "age" | "homeland"となります。
これを使ってKの部分を書き直すと、K extends "name" | "type" | "age" | "homeland"となります。
extendsは指定した型のサブタイプを要求するという制約で、今回はユニオン型に対してなので"name" | "type"や"type" | "homeland"などユニオン型に含まれる要素だけで作ったユニオン型を必要とするという意味になります。
まとめると、型定義のRequiredProperties<T, K extends keyof T>は、
- 型
Tと型Kから新しい型を作る - ただし、
-
Tは何でもよい -
KはTの全てのプロパティ名の中から任意のプロパティ名を組み合わせたユニオン型である必要がある
-
という意味になります。
次に定義内容の部分です。Omit<T, K>とPick<T, K>はどちらもRequired<T>と同じようにビルトイン型で
-
Omit<T, K>: 型Tのプロパティのうち型Kに含まれるプロパティを削除した新しい型を定義する -
Pick<T, K>: 型Tのプロパティのうち型Kに含まれるプロパティだけを取り出した新しい型を定義する
といったものです。
これを踏まえてOmit<T, K> & Required<Pick<T, K>>を&で前後に分解して考えると、
-
Omit<T, K>: 型Tのプロパティのうち型Kに含まれるプロパティを削除した型 -
Required<Pick<T, K>>: 型Tのプロパティのうち型Kに含まれるプロパティだけを取り出した型で全てのプロパティが必須
となります。
最後にまとめると、&は型のマージなので、型Tから型Kに含まれるプロパティを一旦削除しておいて、削除したプロパティだけでできた型の全てのプロパティを必須とする型とマージするということになります。
例えば、
RequiredProperties<Player, "name" | "type" | "age">
は、Omit<T, K> => Omit<Player, "name" | "type" | "age">の部分で、
interface OmittedPlayer {
homeland?: string
}
となり、
Pick<T, K> => Pick<Player, "name" | "type" | "age">の部分で、
interface PickedPlayer {
name: string
type: string
age?: number
}
となり、さらにRequiredがつくことで、
interface RequiredPickedPlayer {
name: string
type: string
age: number
}
のようにageの?が取れて、最後に
interface OmittedPlayer {
homeland?: string
}
とマージすることで
interface PlayerSomePropertyRequired {
name: string
type: string
age: number
homeland?: string
}
が得られます。