📘

TypeScriptのちょっぴり高度な型を学んでみた

2024/03/24に公開

この記事について

『プロを目指す人のためのTypeScript入門』という本を読んでけっこう学びになったので備忘録的にまとめ。
https://www.amazon.co.jp/プロを目指す人のためのTypeScript入門-安全なコードの書き方から高度な型の使い方まで-Software-Design-plus-鈴木-ebook/dp/B09Y527YPV?crid=2WVE6EH79KYRZ&dib=eyJ2IjoiMSJ9.d_meb8YeKw2yuL7kkdsFiIKk5aY6b5EDlKWRWjRT-gO_LGEoJ5VO5tNo-ZEjHFb9FJuEsHSpUv_IghhCuSu2sj-cCYj9953okqaJUa2PrNAQvTXWALnojYDts8ycGoVBz0La_QpiALscaieo1mtAcM9at6_nru4sEoUogjV9Nor4KWgvKdRkDzD2UHSPx66V.0nCRnKRtfwU7SRN6V64Qytr21o1mhjiKohUDTneO6mI&dib_tag=se&keywords=プロを目指す人の為のtypescript入門&qid=1710925800&sprefix=プロを目指す%2Caps%2C171&sr=8-2&linkCode=ll1&tag=ryskiikd-22&linkId=a2c037c841c68c831461a03fd41e00a0&language=ja_JP&ref_=as_li_ss_tl

対象読者

  • TypeScriptでプリミティブ型や自作typeを使ってプログラミングできる
  • ジェネリクスもなんとなく分かる

ちょうど少し前の筆者が上記のレベルだったのですが、そんな人がへぇ〜と思えるくらいの内容になっているかと思います。

オプショナルかundefined型か

オブジェクトのプロパティをオプショナルにするかundefinedとのユニオン型にするかで型推論の挙動が異なる。
下記のコードで、age?: number は実質的にage?: number | undefined と同じ意味として扱われるため、オプショナルなプロパティにundefinedを入れることができる。

// age?: number の例
type Human = {
  name: string;
  // 実質的に age?: number | undefined になる
  age?: number;
};

const sato: Human = {
  name: "sato",
  age: undefined
};

しかし、age?: numberage: number | undefined は意味が異なる。
前者は「ageがない」ということが許されるのに対し、後者は許されない。後者の場合、undefinedでもいいので明示的にageが存在する必要がある。

// age: number | undefined の例
type Human = {
  name: string;
  age: number | undefined;
};

const suzuki: Human = {
  name: "suzuki",
  age: undefined
};

// エラー: Property 'age' is missing in type '{ name: string; }' but required in type 'Human'.
const tanaka: Human = {
  name: "tanaka"
};

age?: numberの場合は、Human型のオブジェクトを作る時にageを省略することでundefinedの場合を表現できる。
しかし、これだと「ageを省略した」のか「ageを書き忘れた」のかを区別できない。つまり、省略が許されているためコンパイルエラーが発生しない。
よって、(省略に特別な意味がない限り)ほとんどの場合では、書き忘れを防げるため、age: number | undefined の方が優れている。

なお、exactOptionalPropertyTypesオプションが有効の場合、age?: number のようなオプショナルプロパティに明示的にundefinedを代入することができなくなる。

type Human = {
  name: string;
  age?: number;
}

const okada: Human = {
  name: "okada",
  // エラー: Type 'undefined' is not assignable to type 'number'.
  age: undefined
}

オプショナルのプロパティに対して、undefinedの代入は許されず、省略だけが可能となる。省略を意図しない場合には、明示的にage: number | undefined と書く必要がある。省略可能かつundefinedも代入可能としたければ、age?: number | undefined となる。

ユニオン型×リテラル型

リテラル型は、可能な値を特定のプリミティブ値のみに限定する機能を持つ。
リテラル型ははユニオン型と組み合わせリテラル型×ユニオン型を作ると有効。

function countNumber(type: "plus" | "minus") {
  return type === "plus" ? 1 : -1;
}

文字列全種類(string型)は必要でなく特定の値のみ必要という場合、ユニオン型とリテラル型の組み合わせると効果的。関数の一部の処理がオプションによって変わる場合などに使うと便利。簡易的なenumのようなイメージ?

リテラル型のwidening

TypeScriptにおける widening は、変数の型を安全に広げる概念を指す。
wideningされるのは主に下記の2パターン

letで宣言された変数

letで宣言された変数は後で再代入されることが期待されるため、変数の型がリテラル型に推論されそうな場合はプリミティブ型に変換する。

// "hello"型
const helloTokyo = "hello";
// string型
let helloOsaka = "hello"

各プロパティがリテラル型のオブジェクト

オブジェクトの場合、constで宣言しても各プロパティがリテラル型であればwideningされる。これはオブジェクトのプロパティはreadonlyでない限り再代入可能なため。

// { name: string; age: number }型
const ito = {
  name: "ito",
  age: 35
};

wideningされないためには

wideningされるリテラル型は、式としてのリテラルに対して型推論で推論されたもののみ。
プログラマが自分で明示的に書いたリテラル型はwideningされないリテラル型になる

// wideningされる"hello"型
const hello1 = "hello";
// wideningされない"hello"型
const hello2: "hello" = "hello";

// string型
let hello3 = hello1;
// "hello"型
let hello4 = hello2;

型の再利用

typeof型

その変数が持つ型を表す。型推論の結果を型として抽出・再利用したい場合に効果的。

const kishida = {
  name: "kishida",
  age: 60
};

// Kは、{ name: string; age: number }型になる
type K = typeof kishida;

https://typescriptbook.jp/reference/type-reuse/typeof-type-operator

lookup型

T[K]という構文を持つ型で、多くの場合、Tはオブジェクト型が、Kは文字列のリテラル型が用いられる。そして、T[K]はTというオブジェクト型が持つKというプロパティの型となる。

type Humn = {
  type: "human";
  name: string;
  age: number;
};

function setAge(human: Human, age: Human["age"]) {
  return {
    ...human,
    age
  };
}

const konno: Human = {
  type: "human",
  name: "konno",
  age: 28,
}

const konno2 = setAge(konno, 29);
console.log(konno2);
// { "type": "human", "name": "konno", "age": 29 }

仮にHuman型のageがnumberからbigint型に変わったとしても、setAge()を変更しなくても済むというメリットがある。ただし、lookup型は一見して具体的な型が分からないため、濫用は良くないとされる。

https://www.typescriptlang.org/docs/handbook/2/indexed-access-types.html#handbook-content

keyof型

オブジェクト型からそのオブジェクトのプロパティ名の型を得る機能。

type Human = {
  name: string;
  age: number;
};

// "name" | "age"型になる
type HumanKeys = keyof Human;

let key: HumanKeys = "name";
key = "age";
// エラー: Type '"hoge"' is not assignable to 'keyof Human'.
key = "hoge";

この例ではHumanKeys型は”name” | “age”という型になる。keyof型の結果はリテラル型(のユニオン型)となる。プロパティのユニオン型を簡単に作りたい時に便利。

keyof型を使って型から別の型を作ることもできる。

const mmConversionTable = {
  mm: 1,
  m: 1e3,
  km: 1e6,
};

// unitがstringの場合
function convertUnits1(value: number, unit: string) {
  // エラー: Element implicity has an 'any' type because expression of type 'string' can't be used to index type '{ mm: number; m: number; km: number; }'.
  const mmValue = value * mmConversionTable[unit];

  return {
    mm: mmValue,
    m: mmValue / 1e3,
    km: mmValue / 1e6,
  };
}

// unitが"mm" | "m" | "km" 型の場合
function convertUnits2(value: number, unit: keyof typeof mmConversionTable) {
  // コンパイルエラーを出さずにアクセス可能
  const mmValue = value * mmConversionTable[unit];
	
  return {
    mm: mmValue,
    m: mmValue / 1e3,
    km: mmValue / 1e6
  };
}

// { "mm": 5600000, "m": 5600, "km": 5.6 } と表示される
console.log(convertUnits(5600, "m"));

convertUnits()の引数unit"mm" | "m" | "km"型になるため、コンパイルエラーを出さずにmmConversionTable[unit]でアクセスが可能。typeofのおかげでmmConversionTableの実装を変えれば自動的に関数の型定義が追随することになる。

https://typescriptbook.jp/reference/type-reuse/keyof-type-operator

ジェネリクスとの併用

function getHumanProperty<T, K extends keyof T>(human: T, key: K): T[K] {
  // 引数によって関数の返り値の型が異なる
  return human[key];
}

type Human = {
  name: string;
  age: number;
}

const sato: Human = {
  name: "sato",
  age: 28
};

// string型
const satoName = getHumanProperty(sato, "name");
// number型
const satoAge = getHumanProperty(sato, "age");

K extends keyof Tは、Kはkeof Tの部分型であることを表す。この制約があれば、KはTが持つプロパティ名の型であることが保証され、T[K]が可能になる。反対に、extends keyof Tがないと、T[K]というlookup型が正しいか分からないためコンパイルエラーになる。

as const

式に対してas constをつけると型推論に対して主に次の3つの効果がある。

  1. 配列リテラルの型推論を配列型でなくタプル型にする。
  2. オブジェクト型はすべてのプロパティがreadonlyで型推論される。
  3. リテラル型がwideningしないリテラル型になる。

これらの性質の中でも、特にwideningしないリテラル型になることが有用。

// string[]型
const namesArray1 = ["sato", "tanaka", "suzuki"];
// readonly ["sato", "tanaka", "suzuki"]型
const namesArray2 = ["sato", "tanaka", "suzuki"] as const;

as constはこの性質を活かして値から型を作るのに使われる場面がある。先程の例のnamesArray1とnamesArray2を比べると、namesArray2の方が3種類の文字列リテラル型が現れ、ただのstringに比べて情報量が増えている。

const names = ["sato", "tanaka", "suzuki"] as const;
// type Name = "sato" | "tanaka" | "suzuki"
type Name = (typeof names)[number];

素となるデータをas constで定義してwideningされていないリテラル型のユニオン型を作る。それをtypeofを使って型にしている。

https://typescriptbook.jp/reference/values-types-variables/const-assertion

ユーティリティ型

typescriptにはmapped typesconditional typesといった高度な機能があり、それらにより可能になった操作の中でも特に有用なものが、ユーティリティ型として標準ライブラリに用意されている。

mapped typesconditional types はかなり複雑らしいので、凡人はユーティリティ型を利用できれば十分そう。
https://typescriptbook.jp/reference/type-reuse/utility-types

Discussion