全集中 TypeScriptの呼吸 壱ノ型Mapped Types、弐の型Conditional Types、そして12の型のその先 type-fest


例えば

class Dog {
  name: string
  age: number
  constructor (dogData: any) {
    this.name = dogData?.name
    this.age = dogData?.age
  }
  cry = () => {
    console.log(`I’m ${this.name}`)
  }
}

みたいなクラスがあった時に、constructorで受け取る型として

{
  name: string
  age: number
}

みたいなのが欲しくなる事があります。筋力で解決するなら

type DogData = {
  name: string
  age: number
}

const dataIsDogData = (data: unknown): data is DogData => {
  const d = data as DogData
  if (typeof d?.name !== 'string') return false
  if (typeof d?.age !== 'number') return false
  return true
} 

class Dog {
  name: string
  age: number
  constructor(data: unknown) {
    if (!dataIsDogData(data)) throw new Error('unknown data')
    this.name = data.name
    this.age = data.age
  }
  cry = () => {
    console.log(`I’m ${this.name}`)
  }
}

みたいになると思います。でもプロパティが10個の場合とかを想像するとちょっと筋肉が身震いしちゃいますよね。Proto3やGraphQL Queryを元にgenerateされているなら別に人力で書く必要のない型(IDLを書く手間はある)なんですが、そういう手法が取れない事もあるかもしれません。

そんな時の選択肢の1つはUtility Typesを利用する事です。TypeScriptにはUtility Typesと呼ばれる便利な型がいくつか標準で搭載されています。例えばその中の1つであるPickを使う事で

type DogData = Pick<Dog, 'name' | 'age'>

といった風に書く事もできます。

でもプロパティ名を10個も羅列しないといけないのは、筋力に自信が無い人だとまだちょっと不安が残りますよね。犬だけじゃなく猫も増えるかもしれないし、10個のプロパティのうちnameだけが無くて代わりにserialNumberを持っているRobotの取り扱いを始める可能性だってあります。叶うならDogやCatやRobotからFunction型だけ取り除いた型を手に入れられないか。

そんな時の選択肢を広げてくれるのがMapped TypesConditional Typesです。

type DogData = {
  [Key in keyof Dog]: Dog[Key] extends Function ? never : Dog[Key]
}

制御構造みたいな見た目ですね。この中の

{[Key in keyof Dog]: Hoge}

のfor in構文みたいな部分はMapped Types

Dog[Key] extends Function ? never : Dog[Key]

の三項演算子みたいな部分はConditional Typesと呼ばれているものです。

壱ノ型Mapped Types

Mapped Typesはその型の各プロパティの型に対して何らかの操作を加えた新たな型を返します。(keyof Tを渡す使い方なら)

例えばUtility TypesのPickやReadonly等もMapped Typesを利用して作られた型です。

type ReadonlyDog1 = Readonly<Dog>

は、敢えてUtility Typesや型変数を使わずに書き直すなら

type ReadonlyDog2 = {
    readonly [Key in keyof Dog]: Dog[Key]
}

type ReadonlyDog3 = {
    readonly [Key in 'name' | 'age' | 'cry']: Dog[Key]
}

のように書くこともできます。これは元となった型の各プロパティに対してreadonlyを与えた型を返しています。また必ずしも既存の型をベースにする必要もなくて

type Meta = {
  [Key in 'title' | 'description'] : string
}

と書くのは

type Meta = {
  title: string
  description: string
}

と書くのと等価です。

弐の型Conditional Types

三項演算子です。

type Result = Pig extends Eatable ? Pork : Pet

type Eatable = {
  grade: number
}

type Pork = {
  grade: number
}

type Pet = {
  name: string
}

という型に対してもしもPigが

type Pig = {
  grade: number
}

だった場合にはResultは

type Result = {
  grade: number
}

になります。

Mappded TypesとConditional Typesを組み合わせる

type DogData = {
  [Key in keyof Dog]: Dog[Key] extends Function ? never : Dog[Key]
}

これらをまとめると「Dogの各プロパティ値の型に対してFunction型(に代入可能)ならnever型を、そうでなければそのプロパティ値の型をそのまま返す」型になります。never型というのは、触るとコンパイラに殺されるので触られていない事がコンパイラによって保証される型です。

そして、これをDog型とFunction型の組み合わせ以外でも使えるよう型変数TとCに置き換えると

type FilterNot<T, C> = {
  [Key in keyof T]: T[Key] extends C ? never : T[Key]
}

type NonFunctionKeysOnly<T> = FilterNot<T, Function>

type DogData = NonFunctionKeysOnly<Dog>
type CatData = NonFunctionKeysOnly<Cat>
type RobotData = NonFunctionKeysOnly<Robot>

こうじゃ。

だいぶ虫様筋と外眼筋に優しくなりました。ここからneverを除いた型にしたかったり、さらに楽をしたければ、例えばtype-festみたいな型ライブラリを使うのもいいでしょう。例えばtype-festのConditionalExceptを使うと

import { ConditionalExcept } from 'type-fest'

type NonFunctionKeysOnly<T> = ConditionalExcept<T, Function>

type DogData = NonFunctionKeysOnly<Dog>
type CatData = NonFunctionKeysOnly<Cat>
type RobotData = NonFunctionKeysOnly<Robot>

と書く事ができます。この例に限らず、ある型を元に別の型を得たい場合や、Primitive型ほしくない?となった時などにはあると便利です。