型で学ぶTypeScript02ジェネリクス
2021-03-17

ジェネリクス

基本

型を引数として受け取ることができる仕組み。 型を引数として受け取ることができるようにすることで、再利用性を高めることができる。

const log = (value: any) => {
  console.log(value)
    return value
}

例えば入れた値をそのままlogに出力して、値をそのままかえす関数logを定義したとする。 valueの型はanyなので、基本的に何でも入れることができる。 一方で、型の恩恵はanyなので受けることはできないので、

console.log(log('hello').toUpperCase())

のようなことができないし、予測も出てこない。 もちろん少ない種類であればvalue: string | numberなどしてもいいが、オブジェクトだったりした場合は都度都度型宣言が必要になってきて非常にめんどくさい。このような場合に、型も引数として渡せば汎用性高くね?という考えでできたのがジェネリクスです。 使い方は下記の感じ。

const log = <T>(value: T): T => {
  console.log(value)
  return value
}

console.log(log<string>('hoge'))

引数の前部分に<T>とあるのがそう。Tの部分はなんでもいい。TはTypeのTらしい。 使うときはこんな感じ。

console.log(log<string>('hoge'))

実行時に、型情報も一緒に渡す。 複数定義することもできる。

また、上記のような定義であれば、引数の実値から型を推測するので、

log('hoge')

だけで良かったりする。

ジェネリクスとextends

上記の用に便利な機能ですが、場合によっては「これは絶対に欲しい」みたいな制約が欲しくなってくる場合があります。このような場合は、extendsを使用して、型パラメータに制約を付ける必要が出てきます。

const log = <T extends {value: string}>(value: T): T => {
  return value
}

このようにすることによって型Tは、オブジェクトで且、少なくともvalueというキーを持っている必要が出てきます。シンプルにTだけだと、unknown型になってしまうので、extendsキーワードはよく使う見たい。

objectのキーのユニオン型を生成する

type K = keyof { name: string; age: number }

このようにkeyofを使用することで、objectのキーを取り出して、Kにユニオン型として代入することができる。 このときのkは推論では下記のようになっている。

type K = "name" | "age"

どういうときに使うのか。 例えば、第一引数にstaffのオブジェクトを渡し、第二引数にそのキーを渡すことで、そのstaffのオブジェクトの情報を取得できるQuestion関数を考えてみます。 また、スタッフは、最低限必要な基本情報として、name, age, postの3つを持ちますが、他の項目は任意であるとします。 このような場合には、keyofとジェネリクスを使用して、下記のように書くことができます。


const question = <T extends {name: string, age: number, post: string} , U extends keyof T>(staff: T, key: U) => {
  return staff[key]
}

実際に使用してみる。

const staff = {name: 'taro', age: 28, post: 'manager', hobby: 'game' }

基本情報の他に、hobbyを持つスタッフを宣言しておきます。

question(staff, '')

この第二引数には、Tのキーが入るように定義してあるので、エディタの補完が下記のようになります。

[gazo]

このようにすることによって、第二引数の型を動的に制御することができます。

クラスに対してのジェネリクスの使い方

クラスに対しても、ジェネリクスを使用することができる。

class SomeArray {}
const someArrayString = new SomeArray()

このようにクラス定義があったとして、someArrayにジェネリクスを使う方法はこんな感じ。

class SomeArray<T extends string | number> {
  private data: T[] = []
  add(item: T) {
    this.data.push(item)
  }
  remove(item: T) {
    this.data.splice(this.data.indexOf(item), 1)
  }
  get() {
    return this.data
  }
}
const someArrayString = new SomeArray<string>()

const someArrayNumber = new SomeArray<number>()

dataとして、stringか、numberのどちらかの配列を生成するという情報を付与できる。 ユニオン型とも似ているが、ユニオン型にした場合、この配列にはstring , numberが混在することになる。

type(インターフェース)にジェネリクスを使用する

typeにもジェネリクスを使用できる。

type Sample<T> = {
  id: string
  sample: T[]
}

const sample:Sample<number> = {
  id: '1',
  sample: [223]
}

interfaceも書く場所は一緒。

内蔵Utility型

はめっちゃあるので、紹介しない。PartialとかReadOnlyとかある。 ここではPromise型について少し触れたい。 (pacage.jsonでtargetがes6以降であることを事前に確認しておくこと。)

const fetchData = new Promise((_resolve) => {
  setTimeout((resolve: string) => {
    console.log(resolve)
  }, 5000)
})

例えば、何かしらデータを取得する関数があったとして、戻り値の型を知っていたとする。しかし、PromiseはPromise<unknown>のジェネリック型なので、戻り値に対しての補完は効かない。そういう場合にも、ジェネリクスを使うことで型を制御することができる。

const fetchData: Promise<string> = new Promise((_resolve) => {
  setTimeout((resolve:string) => {
    console.log(resolve)
  }, 5000)
})

このようにすることによって、 [gazo] のように、戻り値の型が提示されているので、補完が効くようになる。

身近な例では、Arrayなどがよくある。 [gazo] のように、配列型を指定すると、その中身を型引数を指定してくれという旨のエラーがでます。 エイリアス?としては

const stringArray: string[] = ['hoge', 'huga', 'piyo']

こういう書き方でもいい。

型にデフォルト値を指定する

型にデフォルト値を入れるには、=演算子を使う。

type Sample<T = {id: string, sample: Array<string>}> = {
  id: string
  sample: T[]
}

具体的には、このようにする。これで、使用するときにデフォルト値が指定され、指定がない場合はこちらのデフォルト型パラメータが指定される。

MappedTypes

型のfor文である。

type MappedTypes = {
  [P in 'engineer' | 'backOffice' | 'productOwner']: string
}

とか

type MappedTypes = {
  [P in keyof Jobs]: string
}

とかすると、

type MappedTypes = {
    engineer: string;
    backOffice: string;
    productOwner: string;
}

こんな結果が取れる。

一般的には

type GeneralMappedTypes<T> = {
  [P in keyof T]: string
}

こんな感じにして汎用的に使えるようにしていることが多い。

readonlyや-をつけて除外や追加を行うこともできる。

ConditionalType

型のif分岐とか三項演算子的な感じのもの。

extends以下の条件によってtypeの分岐を行う。

type ConditionalTypes = Engineer extends MofmofStaff ? MofmofStaff : Guest

上記のConditionalTypesは、Enginerr型がMofmofStaff型に代入できるなら、MofmofStaff型になり、そうでないなら、Guest型になる。

inferを使って推論する。

こんな事もできるらしい。

type ConditionalTypesInfer = Engineer extends { name: infer R; role: infer L }
  ? { name: R; role: L }
  : boolean

inferによって最も親しい型に推論される。 上記の例では嬉しさはないが、inferでany的な感じで使える

DistributiveConditionalTypes

左辺がユニオン型の場合。

type Engineer = {
    name: string;
    role: string;
}
type BackOffice = {
    name: string;
    hasKey: boolean;
    telNum: number;
}
type MofmofType = {
    name: string;
    role: string;
}

type DistributiveConditionalTypes = (Engineer | BackOffice) extends MofmofType ? MofmofStaff : Guest

直接記述した場合は、どちらの型も代入可能であればtrue判定が起き、そうでなければfalse判定になる。上記の例で行くとfalse判定。 一方で、ジェネリクスを使うと


type DistributiveConditionalTypes<T> = T extends MofmofType ? MofmofStaff : Guest

let tmpObject: DistributiveConditionalTypes<(Engineer | BackOffice)>

この場合のtmpObjectの戻り値の型は下記のようになる。

let tmpObject: mofmofStaff | Guest

直接記述する場合だと、Engineer 型も BackOffice型もどっちも入れるオブジェクト型をMofmoftype型に代入できるかを検証するので、戻り値の型はGuest型になる。ジェネリクスを使用した場合、分割代入になるので、Engineerと同じ型のMofmofStaffはtrue判定、BackOfficeはfalse判定になり、上記のような結果になる。

組み込みUtilityのNonNullableとかの内部定義で使っている。