♨️

TypeScriptの高度な型をマスターする

2024/05/05に公開

先週、TypeScriptの3つの黄金ルール(独自解釈)について説明しました。

その中で第三のルール「型の派生を優先する:汎用型(ジェネリクス)」は特に実装が難しいものです。

型システムが効果的に機能するのは、その定義が正確であるためです。
しかし、より正確になるほど、型の複製が増え、コードが保守不可能になるリスクが高まります。その解決策は、派生(=他の型から型を定義すること)を利用することです。

しかし、簡単ではありません。そこで今日は、型を最適に設定するためのいくつかの重要な概念をステップバイステップで紹介します。これらの概念を徐々に理解することで、コードの任意の部分を型付けするための自立を目指すことができるでしょう💪(頑張る)

取り上げる概念

  • オブジェクトのキーを取得する
  • オブジェクトのキーをフィルタリングする
  • クラスを操作する
  • 文字列を操作する
  • inferを使用して既存の型から型を抽出する

オブジェクトのキーを取得する

目的:変数の型を制限して、オブジェクトのキーとして直接使用できるようにする

いくつかのキーで構成されるオブジェクトの例を考えてみまます。

type Config = {
  sku: string
  name: string
  quantity: number
}

上記の設定を元に、第二パラメータとしてオブジェクトのキーの一つを取るgetConfig関数を作成します。

function getConfig(config, key) {
  return config[key]
}

TypeScriptを使用しない場合、keyは何でも可能であり、hogeのような利用可能な設定には含まれていないものも含むことができます。

そのため、keyをただの文字列ではなく、異なる可能性の組み合わせのユニオンに制約することを検討します。

// ❌ 手動で書くのを避ける
type ConfigKey = 'sku' | 'name' | 'quantity'

このユニオンを自動的に取得するためには、keyof キーワードを使用します。

// ✅ keyof を好む
type ConfigKey = keyof Config

キーの型を取得する方法がわかったので、関数に戻ります。キーと戻り値を正しく型付けするにはどうすればいいでしょうか?

キーの型は、先ほど見たようにkeyof Configです。しかし、戻り値の型はどうでしょうか?最初の反応としては、Config[keyof Config]を使用することかもしれません。

// ❌ 機能が期待通りに動作していません...
function getConfig(config: Config, key: keyof Config): Config[keyof Config] {
  return config[key]
}

これは機能しないでしょう。
なぜなら、もし Config[keyof Config] を分解すると、以下のようになるためです。

Config[keyof Config]
<=> Config['sku' | 'name' | 'quantity']
<=> Config['sku'] | Config['name'] | Config['quantity']
<=> string | string | number
<=> string | number

もしgetConfig(config, 'quantity')を実行した場合、戻り値がnumberのみであることを期待していますが、string が返されることはありません。

そのため、ジェネリクスを使用する必要があります。

function getConfig<Key extends keyof Config>(
  config: Config,
  key: Key
): Config[Key] {
  return config[key]
}

もしgetConfig(config, 'quantity')を実行した場合、戻り値はquantityの値になります。ここでのKeyの型は、利用可能なユニオンの値の一つであり、ユニオン全体ではありません。

このメソッドにより、keyパラメーターはconfigオブジェクトのキーのみになりますので、戻り値はそのキーの型に限定されます。これにより安全性が確保され、非常に精密なオートコンプリートが可能になります。

getConfig(
  { sku: '123', name: 'Product' },
  'name'
)

IDEでgetConfig({ sku: "123", name: "Product" }, '')というコードを書くと、第二パラメータのオートコンプリートはnameskuになります。

事前に定義されたConfig型を使用しています。どのようなオブジェクトにも対応できるようにするにはどうすればよいでしょうか?

解決策

新しい型パラメータを追加することです。これにより、関数に渡されるオブジェクトに応じて設定を適応させることができます。

function getConfig<
+  Config extends Record<string, unknown>,
  Key extends keyof Config
>(config: Config, key: Key): Config[Key] {
  return config[key]
}

⚠️ ただし注意が必要です。どんなタイプの設定でも機能する関数を作りたい場合にのみ意味があります。アプリケーション全体で一つの設定形式しかない場合、それをジェネリックにする必要はおそらくありません。

オブジェクトのキーをフィルタリングする

目的:オブジェクトの型を変換する方法を学ぶ

前の記事で、Omit<T, key>というユーティリティ型について話しました。これは、オブジェクトから一つまたは複数のキーを除外するために使用されます。

type Config = {
  sku: string
  name: string
  quantity: number
}

type LimitedConfig = Omit<Config, 'quantity'>
// { sku: string, name: string }

複数のキーを除外する方法です。

type LimitedConfig = Omit<Config, 'quantity' | 'name'>
// { sku: string }

フィルタリングを行いたい場合、特定の値の型に基づいてキーを選択する新しい型を作成することができます。

例えば、値が文字列型のキーのみを取得したい場合、以下のように型を定義することができます。
まず、Config型からすべてのキーを持ち、それぞれのキーに対してConfigと同じ型を持つ新しい型 StringConfig を作成します。ここで、値が文字列の場合のみをフィルタリングする条件を加えます。

// ❌ まだ完全ではない
type StringConfig = {
  [Key in keyof Config]: Config[Key]
}

問題は、現在のところ値をフィルタリングしていないことです。
これを解決するために、TypeScriptに次のように指示します。もし Config[Key] が文字列型であれば、それを使用し、そうでなければ never 型を割り当てて、そのキーを使用できないことをTypeScriptに伝えます。

// ❌ まだ完全ではない
type StringConfig = {
  [Key in keyof Config]: Config[Key] extends string ? Config[Key] : never
}

この型定義では、quantitynumberではなくneverになっていますが、それは完全には満足できる解決策ではありません。なぜなら、quantityキー自体は依然として存在しており、後でこの型を使用する人にとっては少し誤解を招く可能性があるからです。

まず、never型を返すキーを除外するためには、次の2つのステップが必要です。

  1. 各キーがstring型を持つかどうかを評価します。
  2. never型を返すキーをフィルタリングして除外します。

1. 指定されたコードを使用して、値がneverでないすべてのキーを取得する方法について説明します。以下のコードは、値が文字列型のプロパティのキーのみを抽出するために使用されます。

type StringConfigKeys = {
  [Key in keyof Config]: Config[Key] extends string
    ? Key
    : // ^ 値ではなくキーを返します
      never
}[keyof Config]
//^^^^^^^^^^^^^ { [key]: key } のオブジェクトではなくすべてのキーを取得する

TypeScriptでは、StringConfigKeys'sku' | 'name'と解釈されるようになります。この利点は、quantityキーが完全に消えることです。

2. ステップ1で定義されたキーのみを取得して、全体のオブジェクトを再構築するために、ユーティリティタイプのPickを使用することができます。

type StringConfig = Pick<Config, StringConfigKeys>

こちらが適切にフィルタリングされた新しい型です 🎉

毎回このような処理を行うのはあまり実用的ではありません。その代わりに、以下のような型をどのようにコーディングしますか。

  • RemoveNeverValues<O>: オブジェクト O を受け取り、値が never のキーを除外した新しい型を返す。
  • FilterByValue<O, V>: オブジェクト O を受け取り、値が型 V と同じ型のキーのみを保持する新しい型を返す。

次のように書くことができます。

type StringConfig = FilterByValue<Config, string>

解決策

// 値が never のすべてのキーを除去する
type RemoveNeverValues<T> = Pick<
  T,
  {
    [K in keyof T]: T[K] extends never ? never : K
  }[keyof T]
>

// 型 T から値が V のすべてのキーを除去する
type FilterByValue<T, V> = RemoveNeverValues<{
  [Key in keyof T]: T[Key] extends V ? T[Key] : never
}>

// このフィルタリングされた型は機能しています ✅
type StringConfig = FilterByValue<Config, string>
// { sku: string, name: string }

クラスを操作する

目的:クラスから利用可能なメソッドを抽出する

オブジェクト指向プログラミングでは、クラスで期待されるメソッドを記述するためにインターフェースの使用が一般的です。それは以下のようになります。

type ClockInterface = {
  getCurrentTime(): Date
}

class Clock implements ClockInterface {
  currentTime: Date = new Date()

  constructor(h: number, m: number) {
    // 実装
  }

  getCurrentTime() {
    return this.currentTime
  }
}
もし「interface」と「type」の使い分けについて疑問がある場合は、こちらをクリックしてみてください

TypeScriptにおけるtypeinterfaceの2つの違いは以下の通りです。

1. インターフェースの主な利点は、extends キーワードを使用して継承を行うことができる点です。これにより、従来のオブジェクト指向プログラミングにおける継承を非常によく模倣することができます。

interface AnimalInterface {
  eat(): void
}

interface DogInterface extends AnimalInterface {
  bark(): string
}

2. 同じインターフェースを複数回宣言すると、それらが互いにマージされるという点です。

interface Window {
  addEventListener: EventListener
}

interface Window {
  body: HTMLBodyElement
}

// Window型でタイプされたすべてのオブジェクトは、
// addEventListenerメソッドとbodyを持つ必要があります。

TypeScriptでは、同じ名前の型を複数回宣言すると、「Error: Duplicate identifier 'Window'.」というエラーが発生します。
そのため、型の定義を分散させすぎないように、typeを使用することを好みます。ただし、既存のAPIを拡張する可能性があるプラグインシステムなど、他に方法がない場合に限りinterfaceを使用します。

ClockInterfaceを見ると、コンストラクタが型指定されていないことがわかりますが、コンストラクタも型指定する方法はどうすればいいですか?

実際にクラスをコーディングするとき、実際には二つの異なるものが作成されます。

  1. コンストラクタ:オブジェクトを構築するために存在するもの(newを使って呼び出される関数)です。
  2. インスタンス:instance = new Class()を実行した後に操作するオブジェクトです。

もしコンストラクタの型を表現したい場合は、以下のように書く必要があります。

type ClockConstructor = new (hour: number, minute: number) => Clock

💡 一般的に、型変数を導入する際にこのジェネリック型を使用できます。

export type Constructor<T> = new (...args: unknown[]) => T

// 静的プロパティを追加する
export type ConstructorWithStatics<
  T,
  S extends Record<string, unknown>
> = Constructor<T> & S

逆に、コンストラクターからインスタンスを取得したい場合は、TypeScriptのネイティブタイプを使用することができます。

export type Clock = InstanceType<ClockConstructor>

文字列を操作する

目的:オブジェクト内で動的なキーを作成する

以前に見たように、string型は'sku' | 'name' | 'quantity'型と同じではありません。

そのため、文字列の管理にはより多くの安全性をもたらすことができます。特に、これを可能にするのがTypeScriptのテンプレートリテラルの管理です。

JavaScriptでのテンプレートリテラルは以下の構文です

const hello = `Hello ${name}`

同様に、TypeScriptでは型レベルで同じことができます。

type WithId<S extends string> = `${S}Id`

type UserId = WithId<'user'>
// type: "userId"

❓ オブジェクトに動的にキーを追加するにはどうすればいいのだろうか?

type Values = {
  name?: string
  quantity?: number
}
type HasValues = {
  hasName: boolean
  hasQuantity: boolean
}

文字列 "XXX" を "hasXXX" に変換する方法を説明します。

テンプレートリテラルを使用して has をプレフィックスとして付けることができます。

ただし、hasnameではなくhasNameとしたいので、必要な大文字を追加するためにCapitalizeを使用する必要があります。

type HasNameKey = `has${Capitalize<'name'>}`
// type 'hasName'

オブジェクトの各キーに対して行う必要があります。したがって、次のようなコードになります。

type HasValues = {
  [Key in `has${Capitalize<keyof Values>}`]: boolean
}

💡 奇妙に思えるかもしれませんが、それはkeyofキーワードの位置に関するものです。通常のコーディングでは、for (let key of values)のように書き、その内部で変換を行います。

しかし、ここでは逆のようです。keyofがすべての変換の内部にあります。

type Key = Capitalize<'name' | 'quantity'>

type Key = Capitalize<'name'> | Capitalize<'quantity'>

各ケースを自分で構築し、中間に|を置くのではなく、できるだけ内側に|を置いて、全てを書き直す必要がないようにしましょう。
そして、'name' | 'quantity'のユニオンをどのように取得するかというと、keyofを使用します。

Capitalize<keyof Values>

inferを使用して既存の型から型を抽出する

目的:既存の型から内部型を取得する

最後に、型システムをよく理解するために本当に重要な要素は、ある型から別の型を抽出する方法です。これにより、型をうまく操作し、ジェネリックに渡す型変数の数を限定することができます。

これを学ぶために、まずは関数がどのように型付けされているかを見てみましょう。

function sum(a: number, b: number): number

実際に、型の定義だけに焦点を当てると、このようなスタイルになります。

type Sum = (a: number, b: number) => number

Sumから戻り値の型をどのようにして取得するのか?

もしTypeScriptのドキュメントを既に見ていたら、ReturnTypeというユーティリティ型があることに気づいたかもしれません。
しかし、仮にこのユーティリティクラスを再コーディングするとしたら、どのように機能するでしょうか?

type ReturnType<F extends Function> = ???

最初の方法は、以前と同様に行うことです。
制約をもう少し詳しく説明して、その一部を使用できるようにします。したがって、Functionを以下で置き換えます。

// ❌ 動作しない
type ReturnType<F extends (...args: unknown[]): Result> = Result

問題は、現時点でResult型の変数が存在しないことです。もし追加する必要がある場合、それはFの前に置かなければなりません。しかし、利用できるのはSumだけです。

解決策は、キーワードinferにあります。これはTypeScriptに型を推測させ、推測に成功したかどうかに応じてその型を使用するよう指示します。もっと具体的には、推測に失敗した場合を管理する必要があるため、以下のような条件を作ることができるということです。

// ✅ OK
type ReturnType<
  /* 1 */
  F extends (...args: never[]) => unknown
> =
  /* 2 */
  F extends (...args: never[]) => infer Result
    ? Result /* 3 */
    : never /* 4 */
  1. ReturnType<F>の使用は、推論可能な関数に限定しています。そうでない場合、オブジェクトを渡すと、型が互換性がないと警告する代わりに、単にneverを返します。
  2. その後、inferキーワードを使用して、TypeScriptに特定の場所(ここでは関数の戻り値)で型を推論できるかどうかを尋ねます。
  3. もしそうなら、TypeScriptは私たちがinferキーワードで定義した型変数Resultにこの型を設定します。したがって、これを直接使用して、それが私たちの最終的な型であることを示します。
  4. そうでなければ、別の型を返します。非常に頻繁に「never」として、それが不可能であることを示します。この具体的な場合には、/ 1 /で入力として受け取ることができる型を正確にターゲットにしているため、この条件の側には決して落ちません。しかし、それを指定する必要があります。

💡 お気づきかもしれませんが、...args: unknown[]ではなく、...args: never[]と書きました。その説明はバリアンスの話に関連しています。その違いは重要ですが、理解することはそれほど重要ではありません。動く方を使ってください😁

それはいいですが、既存のコードを再利用しただけです。少し異なるコンテキストでどのように適用できるか見てみましょう。

そのために、前の例を再度取り上げます。

type Values = {
  name?: string
  quantity?: number
}

type HasValues = {
  [Key in `has${Capitalize<keyof Values>}`]: boolean
}

HasValuesのキーから元のキーに戻ります。

BaseValueKey<'hasName'> // name
BaseValueKey<'hasQuantity'> // quantity

BaseValueKeyをどのように型付けするかについてですが、同じ4ステップを使用します。

type BaseValueKey<
  /* 1 */
  HasKey extends keyof HasValues
> =
  /* 2 */
  HasKey extends `has${infer Key}`
    ? Uncapitalize<Key> /* 3 */
    : never /* 4 */
  1. 常に入力できるパラメータを最小限に抑えることを考えています(ここではHasValuesのキーのみ)。
  2. 興味のある値が存在する場所でinferキーワードを使用します。
  3. TypeScriptが型を理解した場合は、その型を返します。しかし、ここでの小さな特殊性は、それが大文字であったため、Uncapitalize<T>を使用して逆にする必要があります。
  4. 理解できなかった場合は、通常はneverというデフォルトの型を返します。

inferキーワードは多くの状況で使用できます。特に関数の引数や戻り値、テンプレートリテラルでの使用が見られますが、ジェネリック型にも適用されます。

ElementType<T>が配列の要素の型を抽出するためにどのようにタイプするか?

解決策

type ElementType<A extends Array<unknown>> = A extends Array<infer Item>
  ? Item
  : never

まとめ

これらの技術を組み合わせることで、型システムを細かく設定することができます。ただし、目的はより管理しやすいコードベースを作ることであることを念頭に置いてください。

  • 最終的に、各コード行がより複雑になり、自動化されたテスト以上のものを提供しない場合は、よりシンプルな解決策を選ぶべきです。
  • 一方で、その方がセキュリティが向上し、コードベースのあらゆる場所で使用されるコードに取り組んでいるのであれば、今少し時間を投資して後で楽になる価値があります。

読まれた多くの方々がさらに深く理解して、日々の開発に役立つことを祈っています。

参考資料

Discussion