🫐

ブルーベリー本個人的まとめ

2024/05/27に公開

はじめに

これまで実務で TypeScript(以下 TS)を使用してきましたが、基礎知識が不十分だと感じることが多々ありました。
そこで、TS と JavaScript の再入門のため、『プロを目指す人のための TypeScript 入門』(通称ブルーベリー本)で一通り学び直しました。
せっかく得た知識を定着させるために、個人的に重要だと感じたポイントを備忘録としてまとめてみたいと思います。

1. 基礎編

1.1 TS の利点・静的型付けによるメリット

型安全性

TS のような静的型付け言語では、コンパイラが行う型チェック(後述)により、コードを書いている段階で型のミスを検出できる。これをコンパイルエラーという。
これにより、ランタイムエラー(プログラム実行時に出るエラー)を減少させ、バグの早期発見ができる。

ドキュメント化

TS のような静的型付け言語では、型がソースコード上に書かれるので読解の助けになる。
例えば以下のような関数を定義した場合、読み手は1行目を見ただけで「number 型の引数を受け取って string 型の値を返すんだな」ということが分かる。

function exampleFunc(arg: number): string {
  // 処理
}

そのほか、適切な関数名やコメント付与なども組み合わせて、関数の中身を見ずとも何の処理をするかを判別できるようになる。
このように、型がコードのドキュメント化の一助になっている。

1.2 コンパイラの役割

Typescript におけるコンパイラの役割は、以下 2 つがある。
主に、開発者が安全で保守性の高いコードを書くサポートをしてくれる。

型チェック

TS では、開発者は型注釈を通して TS コンパイラに情報を提供する。
コンパイラはこの情報をもとに型チェックをすることで、矛盾したコードを検出してコンパイルエラーを出す。
型チェックは静的なチェックであり、プログラムを実行しなくても行えることがメリット。
(逆に、静的ではないチェックは、例えばユニットテスト・インテグレーションテストなどの「テスト」がある。)

型チェックができることで、エディタ上で即座にミスを発見できるため、「書く・ミスを発見・修正」のサイクルが高速に行える。

トランスパイル

TS コンパイラは TypeScript で書かれたコードを JavaScript に変換する。
これをトランスパイルという。
トランスパイルには2段階ある。1 つ目は型注釈を取り除く段階、2 つ目は新しい構文を古い構文に変換する段階。

各プロジェクトでtsconfig.jsonというファイルで定義し、どのターゲットバージョンの JavaScript に変換するか、どのようなコンパイラオプションを使用するかなどを独自に設定できる。

1.3 プリミティブとオブジェクト

TS・JS において「値」には、プリミティブオブジェクトの2種類に大別される。

プリミティブとは

TS の基本的な値で、それ以上分解できない単一の値。
今のところプリミティブ型としてnumber, string, boolean, bigint, null, undefined, symbolがある。

ちなみに number 型は整数と少数の区別がない。

オブジェクトとは

プリミティブを組み合わせてできたもの。

オブジェクトには、オブジェクトリテラル({ key: value }の形式。つまり連想配列)、配列、関数、クラスがある。

【配列について】
上記の通り TS では、配列はオブジェクトの一種として扱われる。
配列と普通のオブジェクトとの違いは、配列は中身のデータに順番がある、データの個数が固定されていない、プロパティ名がない(アクセスするときはインデックス番号を使う)

1.4 null と undefiend

JS・TS の特徴として、値がないことを示すものにnullundefinedがある。

どちらを使うべきかというのは場合によるし難しいところだが、本書では、undefined を推奨している。
理由としては、TS の言語仕様では undefiend の方がサポートが厚いため。

また「サバイバル TypeScript」でも、undefined を推奨している。
undefined はプログラムを書く上で自然発生しやすいので、null に寄せようと思っても完全に寄せることは難しいとのこと。

https://typescriptbook.jp/reference/values-types-variables/undefined-vs-null

1.5 各型の真偽値への変換

const hoge = ?
console.log(Boolean(hoge)) // true or false

上記で変数 hoge の値を真偽値に変換する場合、false になるのは以下の通り。

false になるもの

  • 数値型: 0, NaN
  • BigInt 型: 0n
  • 文字列型: ""(空文字)
  • null, undefined
  • オブジェクト・配列型: すべて true(空オブジェクト・空配列でも true なことに注意

それ以外は true。

1.6 論理演算子を用いた短絡評価

論理演算子&&, ||, ??を用いて、やや複雑な条件分岐を短く書くことができる。

const x = a && b;
const y = a || b;
const z = a ?? b;
  • &&は、a が true ならば b を返し、a が false ならば a を返す
  • ||は、a が false ならば b を返し、a が true ならば a を返す
  • ??は、a が null または undefinede ならば b を返し、a がそれ以外ならば a を返す

??||と似ているが、「データがない場合は代替の値を使う」というシチュエーションに特化している。

1.7 ==と===の違い

===は型まで一致しているかを判定するが、==は暗黙の型変換をした上で比較し型が異なっても true を返す場合がある。
===が厳密な一致判定であり意図しないプログラムを書くことを防止できるので、基本的に===を使うべき。

==を使っても良い場面は一つだけ。
x == nullの比較をする時、これは「null または undefined を判定する」ことになる。どちらもデータがないことを表す似た値であるので、両者を同じ取り扱いしたい場面は多くある。
x === null || x === undefinedと同じ意味になるが、より短く書けるのでx == nullの方が好まれる場合がある。

2. 入門編

2.1 オブジェクトはいつ同じなのか

TS では、オブジェクトがいつ同じなのかに注意する必要がある。

結論、明示的にコピーしなければ、同じオブジェクトを参照していることになる。
例えば、スプレッド構文は「明示的なコピー」なので異なるオブジェクトになる。

const foo1 = { num: 1234 };
const foo2 = foo1;
console.log(foo2.num); // 1234
foo2.num = 0;
// foo2はfoo1と同じオブジェクトを参照しているので、foo1.numも書き換えられることになる
console.log(foo1.num); // 0

const foo3 = { ...foo1 };
foo3.num = 100;
// 明示的なコピーなので、foo1のオブジェクトは書き換えられない。
console.log(foo3.num); // 100
console.log(foo1.num); // 0

変数は、オブジェクトそのものではなく、別のところにあるオブジェクトを指し示すものであると考えるべき。
上記例のように、1 つの変数がオブジェクトを占有しているとは限らず、別の場所でも同じオブジェクトを参照しておりそこで書き換えられるということがある。

ただしスプレッド構文によるコピーであっても、ネストしたオブジェクトは相変わらず同じオブジェクトなので、要注意

const foo1 = { obj: { num: 1234 } };
const foo2 = { ...foo1 };
foo2.obj.num = 0;
// ネストしたオブジェクトは同じオブジェクトのままなので、もとの変数も書き換えられる
console.log(foo1.obj.num); // 0

2.2 type と interface

ざっくり説明

  • type 文は TS で最も頻出であり、型名を宣言する文。
  • interface ではオブジェクトの型だけ宣言できる。
type FooBarObj = {
  foo: number;
  bar: string;
};
interface FooBarObj {
  foo: number;
  bar: string;
}

どちらを使うべきか?

ここはブルーベリー本にも詳細に書かれておらず、個人的に調べた内容も書いています。

ほとんどの場合、interface は type 文で代用可能なため、チームでは interface は使用しない方針とするケースもある。

一方、以下の TS 公式ガイドでは、交差型を使用するような時は interface で extends(継承)した方がパフォーマンスが良いとされている。
https://github.com/microsoft/TypeScript/wiki/Performance#preferring-interfaces-over-intersections

ただし、interface では拡張ができるために、知らないところで拡張されていたというケースが起きかねないので type の方が安全性はある。また type の方が直感的に型の宣言であることが分かりやすい。

以上より、最近では type だけ使う派が多いという印象がある。
結局のところ、type だけを使うか interface も使い分けるかの好みは人による。
そのためプロジェクト内で決め事を作っておいた方が良い。

2.3 インデックスシグネチャと Map

インデックス型ともいう。オブジェクト型の中で使用できる記法。
「どんな名前のプロパティも受け入れる」という性質のオブジェクトを記述でき、プロパティをあえて指定せず動的に決めたい場合に使用する。

【記法】
[キー名: string]: 型
キー名は string 型固定で、「任意の string 型のキーに対して」という意図になっている。

【例】

type PriceData = {
  // インデックス型: [キー名: string]: 型
  [key: string]: number;
  // ↑は、任意の名前のプロパティがnumber型を持つという意味
};
const data1: PriceData = {
  apple: 220,
  coffee: 120,
  bento: 500,
};

// プロパティの追加可能
data1.chicken = 250;
// number型以外のプロパティは不可
// data1.bento = 'foo'

また、インデックス型はRecord<K, T>ユーティリティ型を使っても表現できる。

// 以下2つの型注釈は同じ意味
let obj1: { [K: string]: number };
let obj2: Record<string, number>;

インデックスシグネチャの注意点

インデックスシグネチャがあるオブジェクト型では、実際にプロパティが存在するかは無関係に「どんなプロパティにもアクセスできる」という性質を持つ。
そのため、型安全性が破壊される可能性がある。

例えば存在しないプロパティにアクセスした時、TS はコンパイルエラーを発生させず実際は undefiend を返すので、バグの危険がある。

動的なプロパティを持つオブジェクト(連想配列)を扱いたい場合は、型安全な Map オブジェクトで代替できることが多い。

2.4 ジェネリック型

型引数を持つ型。
具体的な型は指定せず「構造」のみを定義したいケースで用いる。
※ ジェネリクスと似ているが別物。

// ジェネリック型
type User<T> = {
  name: string;
  child: T;
};

// 引数としてnumber型を渡している
const makoto: User<number> = {
  name: "makoto",
  // childはnumber型
  child: 1,
};

また、extendsを使って型引数に制約を加えることもできる。

type HasName = {
  name: string;
};
type Family<Parent extends HasName, Child extends HasName> = {
  mother: Parent;
  father: Parent;
  child: Child;
};

// エラー: numberやstringはHasNameの部分型ではないため。
type T = Family<number, string>;

type Animal = {
  name: string;
};
type Human = {
  name: string;
  age: number;
};
// OK: Animal, HumanがHasNameの部分型を満たしているため。
type T = Family<Animal, Human>;

2.5 for-of 文とインデックスアクセス

for-of 文

for-of 文は、配列を扱う際に、一つひとつの要素をループ処理するのための構文。
「one of them(多くの中の一つ)」のofと覚えると良さそう。

【例】

const arr = [1, 10, 100];

// letじゃなくてOK
for (const elm of arr) {
  console.log(elm);
}

for-of 文とインデックスアクセスの需要比較

配列は要素がいくつあるか不明なケースが多い。
一方、コードを書く上では各要素に平等に同じ処理をするというユースケースが多い。
必然的にインデックスアクセスarr[0]的な)よりもfor-ofのほうが需要が高くなる。

インデックスアクセスの危険性

インデックスシグネチャの注意点と似ているが、TS では配列のインデックスアクセスに対しては配慮されていない。

例えば以下のケース。

const arr = [1, 10, 100];
// 実際100番目の要素は存在しないのにコンパイルエラーにはならない
const num: number = arr[100];
consol.log(num);

number[]は number 型の配列であり、型情報の上では要素が何個あるかという情報は存在しない。
このようなケースを回避するためにも、インデックスアクセスは極力使用せず、代わりに for-of 文などの方法を用いることが推奨される。

また、noUncheckedIndexedAccess コンパイラオプションを使用することでも回避できる。

https://typescriptbook.jp/reference/tsconfig/nouncheckedindexedaccess

2.6 分割代入いろいろ

オブジェクトの分割代入

オブジェクトからプロパティの値を変数に代入する操作を簡単にできる。
分割代入で宣言された変数には型注釈がつけられない点に注意。この時変数の型は型推論によって決められる。

const obj = {
  foo: "foo",
  bar: "bar",
};

// オブジェクトの分割代入:
// 1. プロパティと同じ変数名で代入
const { foo, bar } = obj;
// 以下と同じ意味
// const foo = obj.foo
// const bar = obj.bar

// 2. プロパティと別名の変数を使いたい時
const { foo, bar: barVar } = obj;
// 以下と同じ意味
// const foo = obj.foo
// const barVar = obj.bar

ネストパターン

対象のオブジェクトがネストしている場合も、ネストの内側のプロパティを分割代入で取得可能。

const nestedObj = {
  num: 123,
  obj: {
    foo2: "hello",
    bar2: "world",
  },
};

const {
  num,
  obj: { foo2 },
} = nestedObj;
// 以下と同じ意味
// const num = nestedObj.num
// const foo2 = nestedObj.obj.foo2

分割代入のデフォルト値

変数名のあとに= 式を付加することで、その変数に undefined が入る時、代わりにデフォルト値を入れることが可能。

type Obj = { foo?: number };
const obj1: Obj = {}; // fooはundefined
const obj2: Obj = { foo: -1234 };

// 分割代入時にデフォルト値を定義
const { foo = 500 } = obj1;
console.log(foo); // 500(デフォルト値)

const { foo: bar = 500 } = obj2;
console.log(bar); // -1234

【注意点 1】
分割代入によって簡潔に書けるようにはなっているが、直感的に読みづらくなっている。
「undefined でなければ」ということがコード上で明確に書かれていないため、分割代入の暗黙の知識が必要になる。

分割代入を用いずに同じ処理を書くと、undefined がコード上に現れる。

const foo = obj.foo !== undefined ? obj.foo : 500;

【注意点 2】
デフォルト値は undefined に対して適用される。null に対しては何も行われない。

const obj = { foo: null };
const { foo = 123 } = obj;

// デフォルト値が適用されないのでnullが出力される
console.log(foo);

関数引数への分割代入

関数で、引数名の代わりに分割代入を行うことも可能。
以下の例では、関数内でhuman.を 2 回書く必要がなくなり簡潔になっている。

type Human = {
  height: number;
  weight: number;
};
// 関数引数への分割代入
const calcBMI = function ({ height, weight }: Human): number {
  return weight / height ** 2;
};
// 以下と同じ意味
// const calcBMI = function (human: Human): number {
//   return human.weight / human.height ** 2;
// };

const makoto: Human = { height: 1.72, weight: 68 };
console.log(calcBMI(makoto));

その他

オブジェクトだけでなく、配列を対象とした分割代入も可能。
また、rest パターンでオブジェクトの残りのプロパティを新しいオブジェクトに代入することも可能。
詳細は今回省略。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment

2.7 早期リターン

条件式を扱う際に、早期に return を返すことで if 文のネストを減らすテクニック。
以下は簡単な例。

function helloWorldTimes(n: number): void {
  if (n >= 100) {
    console.log(`${n}回なんて無理です!`);
    // 早期リターン
    return;
  }
  for (let i = 0; i < n; i++) {
    console.log("hello world");
  }
}

ネストを減らす以外にも下記メリットがある。

  • パフォーマンス向上
    • 余分な処理をする前にそのメソッドを抜けることができるため。
  • 可読性向上
    • 一般的に、早期リターンをするとコード全体の行数が減る。
  • テストが書きやすい
    • if が複合的になっている場合、組み合わせてテストを書く必要がある。一方早期リターンだと条件の指定がしやすい。
  • メソッドの拡張性が上がる
    • 新しい条件を追加する際も、ネストさせずに if を差し込むだけなので簡単。

こちらの記事がとても参考になりました。

https://zenn.dev/media_engine/articles/early_return

2.8 アロー関数と省略記法

関数の書き方いろいろ

関数の書き方は関数宣言関数式アロー関数の 3 パターンがある。

// 関数宣言
function calcBMI1({ height, weight }: Human): number {
  return weight / height ** 2;
}
// 関数式
const calcBMI2 = function ({ height, weight }: Human): number {
  return weight / height ** 2;
};
// アロー関数
const calcBMI3 = ({ height, weight }: Human): number => {
  return weight / height ** 2;
};
  • 関数宣言では巻き上げが可能(宣言より前にその関数が使える)
  • アロー関数では省略記法や this の扱いなど、他より有利な点がある(後述)。

省略記法

いきなり返り値を計算するような関数(つまり関数の中の式が 1 つだけ)では、省略記法でさらに簡潔に書ける。
特にコールバック関数を書くときによく使われる。

// 通常のアロー関数
const calcBMI3 = ({ height, weight }: Human): number => {
  return weight / height ** 2;
};
// 省略記法
// {}とreturnを書かなくて良い
const calcBMI3_2 = ({ height, weight }: Human): number => weight / height ** 2;

this の扱い

アロー関数では、単純に他記法より文字数を減らすだけでなく、this の取り扱いも異なる。
宣言時の this を束縛して不変のものにするという効果を持っている。
こちらの記事で詳しく説明されています。

https://qiita.com/mejileben/items/69e5facdb60781927929

2.9 コールバック関数

コールバック関数とは、関数の引数として渡される関数のこと。
コールバック関数を引数として受け取るような関数は高階関数と呼ぶ。

TS では、配列のメソッドを取り扱う時(map など)でよく使われる。
コールバック関数は関数に渡されるためだけに作られることが多く、変数に入れずに直接関数式で渡したほうが見通しが良くなるのでおすすめ。

type User = {
  name: string;
  age: number;
};
const users: User[] = [
  { name: "makoto", age: 26 },
  { name: "John Smith", age: 15 },
];

// mapの中がコールバック関数
// mapが高階関数
const names = users.map((user) => user.name);
console.log(names);

配列を操作するメソッドとして、fileter, every, some, find などがよく使われる。

2.10 関数の返り値は明示すべき

実は TS で関数を書く際、返り値の型を省略することができる。(省略した場合は型推論される)

しかしながら、以下メリットがあるため返り値はなるべく明示すべき。

  • 可読性向上:関数が長くても、中身を読まなくても何が返るか一目でわかり見通しが良くなる。
  • コンパイル支援:関数内部で返り値の型に対して型チェックを働かせられる。返り値の記述をミスしても、関数内部や定義のなるべく早い時点でコンパイルエラーが発生するので、気づきやすい。

部分型関係と TS の責任範囲

S が T の部分型ならば、
同じ引数リストに対して(引数リスト) => Sという関数方が(引数リスト) => Tという関数の部分型となる。
つまり、関数から返ってきた S 型の値を、T 型の値の代わりに使える。

以下の例では、HasNameAndType(S)が HasName(T)の部分型。

type HasName = {
  name: string;
};

// HasNameの部分型
type HasNameAndAge = {
  name: string;
  age: number;
};

const fromAge = (age: number): HasNameAndAge => ({
  name: "John Smith",
  age,
});

// fromAgeはHasNameAndAge型だが、HasNameの部分型なので代入可能
const f: (age: number) => HasName = fromAge;

const obj100: HasName = f(100);
// obj100 = { name: 'John Smith', age: 100 }
// HasName型だが、ageプロパティもある

上記最後のように、TS では部分型関係の影響で、型情報より多いプロパティが返されることがある。
言い換えると、TS では型情報に合わせて情報が削られるようなことは起こらない。(今回の例でいうと、HasName により age プロパティが削られることはない)

TS の「型情報がランタイムの挙動を与えない」という原則に沿っている。

3. クラス

3.1 コンストラクタ

コンストラクタについて簡単に説明。

  • new によりインスタンスが作成される際に呼び出される関数。
  • プロパティの初期化を担う。

3.2 修飾子

readonly とコンストラクタ

readonly は、読み取り専用(つまり値の変更できない)プロパティだが、コンストラクタでは値の変更が可能。
これは、「いったんオブジェクトを作ったら変更できない」という意味なのに対し、コンストラクタはオブジェクトを作っている最中の操作であるため。

class User {
  name: string;
  readonly age: number;

  constructor(name: string, age: number) {
    this.name = name;
    // コンストラクタでは可能
    this.age = age;
  }

  setAge(newAge: number) {
    // ↓クラス内でもコンストラクタ以外ではエラー
    // this.age = newAge
  }
}

const makoto = new User("makoto", 26);
// ↓インスタンスのプロパティに直接代入もエラー
// makoto.age = 29

static

静的プロパティ・静的メソッドのことで、インスタンスではなくクラスそのものに属するプロパティ・メソッド。
TS では、クラスはそれ自体が一種のオブジェクトになる。そのため、クラス自身もプロパティを持つことができる。
ただし、静的プロパティの利用が必須であるという場面はあまりない。クラス内ではなく別個に用意することも可能。

class User1 {
  // 静的プロパティ
  static adminName: string = "makoto";
  static getAdminUser() {
    return new User1(User1.adminName, 26);
  }

  name: string;
  age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }
}

console.log(User1.adminName); // makoto

const makoto = new User1("makoto", 26);
// adminNameはstaticなのでインスタンス化した後はアクセスできない
// console.log(makoto.adminName)

アクセシビリティ修飾子

ざっくり説明

クラス内のプロパティ・メソッドにはアクセシビリティ修飾子をつけることができる。
これは、そのプロパティ・メソッドに対してどこからアクセスできるかを型システム上で制御するもの。
厳格な順に上から以下3種類ある。

  • public
    • どこからでもアクセス可能。
    • 何も書かなかったら public になる。
  • protected
    • そのクラス自身と、**そのクラスを継承したクラス(子クラス)**からアクセス可能。
  • private
    • クラス内からしかアクセスできない。
    • つまり、private プロパティ・メソッドは「内部実装」になり、インスタンスを使う側からは無関係の存在になる。
    • #プロパティ名でもプライベートプロパティとして宣言できる。

private と#の違い

private# はいずれもプライベートプロパティを意味するが、いくつか違いがある。

  • privateが TS の機能であり JS にコンパイルされた後は普通のプロパティになる。一方、#は JS の機能でありランタイムでもプライベート性が守られるため、より厳格になる。
  • また、継承を多用するときは#のほうが便利な場面がある。

そのため、迷ったらprivateより#を使うのが良さそう。

継承による挙動の違いは、以下の通り。
privateでは親と子で同じ名前のプロパティを定義できないが、#だとできる。

class User1 {
  private age = 0;
}
// class SuperUser1 extends User1 {
//   // これはエラー
//   private age = 1
// }

class User2 {
  #age = 0;
  public isAdult(): boolean {
    return this.#age >= 20;
  }
}
class SuperUser2 extends User2 {
  // これはOK
  // User2のageとは別物
  #age = 20;
  public isAdult(): boolean {
    return this.#age >= 20;
  }
}

const makoto1 = new User2();
const makoto2 = new SuperUser2();

console.log(makoto2.isAdult()); // true
console.log(makoto1.isAdult()); // false → 確かに子クラスによる書き換えされていない

この違いが出る理由は前述の、#がランタイムのチェックであるということに関係している。 #はプロパティ名の名前空間がクラスごとに独立して存在する。(=クラスごとに別々に区切られている)
そのため、継承を多用したい場合はprivateより#の方が(安全性を保った上で)使いやすく便利。

protected は極力使うべきではない

protected を使う場合は、そのクラスの子クラスによりプロパティを書き換えられる可能性を考えた上で実装する必要がある。
つまり、子クラスによる好き勝手な干渉を受け入れる意思表示になる。
子クラスによって書き換えられたら破綻するような実装時は、極力 protected ではなく private を使用すべき。

3.3 コンストラクタ引数でのプロパティ宣言

修飾子を使ってプロパティ宣言とコンストラクタ作成をシンプル化できる。

以下の例では、User クラスのインスタンスは name, age プロパティを持ち、コンストラクタが呼び出された際に初期化する。
修飾子をつけることで、コンストラクタの引数名が、引数名であると同時にプロパティ名としても扱われる。

class User {
  // 従来必要だった記述
  // name: string;
  // private age: number;
  // constructor(name: string, age: number) {
  //   this.name = name;
  //   this.age = age;
  // }

  // 修飾子によって記述シンプル化
  constructor(public name: string, private age: number) {}
}

const makoto = new User("makoto", 26);
console.log(makoto.name);

3.4 オーバーライド修飾子の威力

継承とオーバーライド

  • 継承とは、あるクラス(親クラス)に機能を追加・拡張した別のクラス(子クラス)を作成する機能。
    • extendsにより継承する。
  • オーバーライドは、親クラスの機能を子クラスで再宣言して上書きすること。
    • ただし、親クラスのインスタンスの部分型であるという原則は守ってオーバーライドする必要がある。
  • コンストラクタをオーバライドするためには、子クラスで super 呼び出しを含める必要がある。
    • super呼び出しは、親クラスのコンストラクタを呼び出すための構文
    • コンストラクタのオーバーライドでは、一般的なメソッドと異なり、上書きはできず拡張のみ可能
// 親クラス
class User {
  name: string;
  #age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.#age = age;
  }

  public isAdult(): boolean {
    return this.#age >= 20;
  }
}

// 親クラスの継承
class PremiumUser extends User {
  rank: number = 1;

  // コンストラクタのオーバーライド・superを含める
  constructor(name: string, age: number, rank: number) {
    super(name, age);
    // コンストラクタは拡張のみ可能
    // superより後で定義する
    this.rank = rank;
  }

  // メソッドのオーバーライド
  public isAdult(): boolean {
    return true;
  }
}

const test = new PremiumUser("test", 15, 1);
console.log(test.name);
console.log(test.rank);
console.log(test.isAdult()); // true

override 修飾子による明示

  • TS ではoverrideという修飾子があり、プロパティやメソッドをオーバーライドすることを明示的に宣言できる。
  • この修飾子の使用はデフォルトでは必須ではなく、使っても使わなくても実際の挙動は変わらない。
  • noImplicitOverrideコンパイラオプションと組み合わせると効果を発揮する。
    • これを有効にすると、オーバーライド時は必ずoverride修飾子をつける必要がある。
    • これにより「オーバーライドしたつもりができていなかった」「オーバーライドするつもりがないのにしてしまった」というミスを防ぐことができる。

上記の例を書き換えると以下の通り。

// 子クラス
class PremiumUser extends User {
  rank: number = 1;

  constructor(name: string, age: number, rank: number) {
    super(name, age);
    this.rank = rank;
  }

  // override修飾子によるオーバーライド明示
  public override isAdult(): boolean {
    return true;
  }
}

3.5 implements

implementsは、そのクラスのインスタンスは与えられた型の部分型であるという宣言
宣言の意図を明確にしたい時に使う。
クラスを定義するときに、それをある型に適合させたい場合は、implements が適している。

type HasName = {
  name: string;
};

// User(Userクラスのインスタンスの型)HasNameの部分型であることを宣言
class User implements HasName {
  // もしnameを定義しないとエラー
  name: string;
  #age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.#age = age;
  }
}

4 例外処理

4.1 例外について

例外とは、ランタイムエラーのこと。
TS ではプログラミングする上での多くのミスをコンパイルエラーとして早い段階で検出してもらえるが、それでも検知できないミスや、呼び出し時の外的要因による失敗も多く存在する。

4.2 throw 文と Error オブジェクト

ランタイムエラーを発生させたい時は、エラーを表すオブジェクトとしてError インスタンスを用いる。
そしてthrow 文を用いてランタイムエラーを発生させる。
throw するとプログラムは強制終了(クラッシュ)する。

4.3 try-catch 文

上記で throw は強制終了させると説明したが、例外時もプログラムを続行したい場合はtry-catchを使う

try {
  // tryブロック: 例外が発生しなければcatchは実行されない
} catch (err) {
  // catchブロック: tryでもし例外が発生したらここを実行する
}

// 例
try {
  console.log("エラーを発生させる");
  throwError();
  console.log("ここは実行されない");
} catch (err) {
  console.log(err);
}
console.log("ここは実行される");

function throwError() {
  const error = new Error("エラー発生!");
  throw error;
}

4.4 throw 以外での失敗表現

失敗時に throw でエラーを発生させる場合は、必然的に try-catch が使われる。
一方、throw 以外の失敗を表す選択肢として、失敗を表す値を返すこともできる。

function getAverage(nums: number[]) {
  if (nums.length === 0) {
    // 失敗時はthrowする代わりに、undefinedを返す
    // 失敗判定には、getAverageがundefinedを返したどうかを調べれば良い
    return undefined;
  }
  return sum(nums) / nums.length;
}

throw を使うか、失敗を表す返り値を使うかだが、後者の方が型システム面では扱いやすい。
理由は、throw では catch(err)で変数 err が宣言され catch ブロックでこれを利用することになるが、err が unknown 型になるため、どんな値が来るか全く不明であり catch できちんとした処理を書くことが面倒になるため。
try-catch 文はランタイムエラーを制御できる強力な機能だが、取り扱いは注意が必要。

4.5 try-catch を使うべき場面

とはいえ、例外が持つ大域脱出という特徴を活かせる場合は、try-catch が向いている。

  • 大域脱出:その場での実行を中断して別の場所にプログラムの制御を移すこと。

返り値で失敗を表す方法を採用した場合、関数を呼び出す側すべてで返り値のチェックが必要になる。
一方、try-catch で囲っておけば、その try ブロック中のいろいろな場所で例外が発生する可能性があっても、例外時の処理を catch ブロックでまとめておき共通のエラー処理ができる。

つまり、エラー処理を共通としても問題ない場合は、try-catch 文のほうが書きやすく見るほうも分かりやすい。

しかしながら、異なるエラー処理をさせたい場合は返り値のチェックで制御した方が適切なのでどちらを使うべきかは注意が必要。

5. 高度な型

5.1 リテラル型とユニオン型の組み合わせ

  • ユニオン型は、「型 Tまたは型 U」のようなでT | Uと書く。
  • リテラル型は、特定のプリミティブ値のみに限定する機能。

これらを組み合わせて、特定の文字列だけを引数に受け付けるような関数を作ることができる。

// リテラル型のユニオン型
function signNumber(type: "plus" | "minus") {
  return type === "plus" ? 1 : -1;
}

console.log(signNumber("plus"));
console.log(signNumber("minus"));
// 以下はコンパイルエラー
// console.log(signNumber('wrong'))

5.2 型の絞り込み

ユニオン型は型の絞り込みができるため、非常に利用価値が高い。
型の絞り込みとは、ユニオン型を持つ値が実際にどの値なのかを特定するようなコードを書くことで、特定の型の場合のみの処理を行うことができるようになるもの。

絞り込みには、いくつか方法があるので以下実例で紹介する。
いずれも最初に"none"の可能性を排除し、その後"plus", "minus"のユニオン型に絞り込んで処理を行っている。

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

// ①条件分岐(if)による型の絞り込み
function numberWithSign1(num: number, type: SignType | "none") {
  if (type === "none") {
    return 0;
  } else {
    return num * signNumber(type);
  }
}
console.log(numberWithSign1(5, "none"));
console.log(numberWithSign1(5, "plus"));
console.log(numberWithSign1(5, "minus"));

// ②returnによる型の絞り込み
function numberWithSign2(num: number, type: SignType | "none") {
  if (type === "none") {
    return 0;
  }

  return num * signNumber(type);
}
console.log(numberWithSign2(5, "none"));
console.log(numberWithSign2(5, "plus"));
console.log(numberWithSign2(5, "minus"));

// ③等価演算子による型の絞り込み
function numberWithSign3(num: number, type: SignType | "none") {
  return type === "none" ? 0 : num * signNumber(type);
}
console.log(numberWithSign3(5, "none"));
console.log(numberWithSign3(5, "plus"));
console.log(numberWithSign3(5, "minus"));

5.3 as const

as(型アサーション)の使用は TS の恩恵を受けられなくなるので基本的に避けるべきだが、as constはプログラムの安全性を向上させる良い機能。

as constは複数の作用を持つが、as const が付けられた式に登場するリテラルを「変更できないもの」として扱うと理解すれば良い。
特に、リテラル型の widening を防止できることの有用性が大きい。

// 普通の変数定義
// string[]型
const names1 = ["makoto", "John", "Taro"];

// as constによる変数定義
// readonly ["makoto", "John", "Taro"]型 → wideningしないリテラル定義になっている
const names2 = ["makoto", "John", "Taro"] as const;

5.4 any 型と unknown 型

any 型と unknown 型は、どんな型でも受け入れるという意味で共通している。
まとめると以下の通り。使用する際に型チェックが行われるかどうかにより安全性が大きく異なっている。

  • any型
    • どんな型でも受け入れる。
    • 型チェックなし。
    • 型安全性が低い。
  • unknown型
    • どんな型でも受け入れるが、型チェックが必要。
    • 型安全性が高い。
// unknown型: 何がくるか全くわからない状況で使う
function doNothing(val: unknown) {
  console.log(val);

  // プロパティアクセス不可
  // const name = val.name
}

function useUnknown(val: unknown) {
  // 型の絞り込みにより利用可能
  if (typeof val === "string") {
    console.log("valは文字列です");
    // 文字列としての処理が可能
    console.log(val.slice(0, 5));
  } else {
    console.log("valは文字列以外の何かです");
    console.log(val);
  }
}

useUnknown("foobar"); // valは文字列です fooba
useUnknown(null); // valは文字列以外の何かです null

5.5 組み込みの型

組み込み型は、標準ライブラリに用意されており何もせずとも利用できる型操作。

  • ReadOnly<T>: T(オブジェクト型)の全てのプロパティを読み取り専用にする。
  • Partial<T> : T(オブジェクト型)の全てのプロパティをオプショナルにする。
  • Required<T> : 逆に、T(オブジェクト型)の全てのプロパティからオプショナルをなくす。
  • Pick<T, K> : T(オブジェクト型)のうち、K で指定した名前のプロパティを抽出する。
  • Omit<T, K> : 逆に、T(オブジェクト型)のうち、K で指定した名前のプロパティ以外を抽出する。
  • Extract<T, U> : T(ユニオン型)のうち、U の部分型であるもののみを抜き出したユニオン型を作成する。
  • Exclude<T, U> : 逆に、T(ユニオン型)のうち、U の部分型であるものを取り除いたユニオン型を作成する。
// ReadOnly<T>
type T1 = Readonly<{
  name: string; // = readonly name: string
  age: number; // = readonly age: number
}>;

// Partial<T>
type T2 = Partial<{
  name: string; // = name?: string | undefined
  age: number; // = age?: number | undefined
}>;

// Pick<T, K>
type T = {
  name: string;
  age: number;
};
type T3 = Pick<T, "age">; // T3 は {age: number}
// Omit<T, K>
type T4 = Omit<T, "age">; // T4 は {name: string}

// Extract<T, U>
type Union = "makoto" | "mako" | 1 | 2 | 3;
type T5 = Extract<Union, string>; // "makoto" | "mako"
// Exclude<T, U>
type T6 = Extract<Union, string>; // 1 | 2 | 3

6. 非同期処理

通信が必要な処理やファイルの読み書きなど、時間がかかる処理は非同期処理として裏で行わせる。
非同期処理が終わった時、コールバック関数を呼び出して終わったことを検知する。

6.1 非同期処理の書き方

非同期処理の書き方には2種類ある。
①コールバック関数を直接渡す方式と、②PromiseベースのAPIを使う方式

import { readFile } from "fs";
import { readFile } from "fs/promises";

// 非同期処理の書き方
// ①コールバック関数を直接渡す方式
readFile("src/foo.txt", "utf8", (err, result) => {
  console.log(result);
});
console.log("読み込み開始");

// ②PromiseベースのAPI
// Promiseオブジェクトを返すという点が共通なので①より使いやすい
const p = readFile("src/foo.txt", "utf8");
// 成功時
p.then((result) => {
  console.log("成功", result);
});
// 失敗時
p.catch((error) => {
  console.log("失敗", error);
});

非同期処理ではエラーハンドリングが重要になる。
① ではコールバック関数の引数としてエラーが渡されているが、どのような引数(数、型)で渡されるかは API の仕様によるため調べる必要がある。
また、上記では err として引数を受け取っているもののハンドリングできておらず、このように簡単に無視できてしまうため注意が必要。

② では、非同期処理の関数(readFile)はコールバック関数を受け取らず、Promise オブジェクトp を返している。この p に対して、終わった後に行う処理を記述する。
非同期処理の成功時はthen, 失敗時はcatchブロックの処理が実行される。

6.2 Promise による抽象化の成果

前節のように「非同期処理そのもの」を表す抽象化された Promise オブジェクトが用意されたことには大きなメリットがある。

  • ① では、コールバック関数への引数の渡し方が API ごとに異なるため、毎回調べる必要がある。一方、② ではどんな関数でも「Promise を返す」点で共通しており、後続の処理の形も共通化されている。
  • Promise.all などのように Promise オブジェクトそのものを取り扱う機能があり、どんな Promise に対しても共通して同じ機能が使える。

6.3 失敗した Promise のコールバック関数が登録されていなかった時

失敗時の関数が存在しないプログラムを実行するとUnhandledPromiseRejectionが表示される。
そのため、失敗の可能性がある Promise は必ず catch などでエラー処理を行う必要がある。

また、上記 ② では、1 つの Promise に対して then と catch を別々に呼び出しているが、これは本来するべきではない。(p.then ブロックではエラーハンドリングがされていないため)
代わりに、Promise チェーンを使って、失敗の可能性があるのにコールバック関数が登録されない「取りこぼし」を防ぐことができる。

const p2 = readFile("src/hoge.txt", "utf8")
  .then((result) => {
    console.log("成功", result);
  })
  .catch((error) => {
    console.log("失敗", error);
  });

6.4 async/await

説明

非同期関数を扱うための便利な機能として、async/await がある。
これは Promise をベースとしており、

  • async 関数の返り値は必ず Promise になる。
  • await は async 関数の中で使い、与えられた Promise の結果が出るまで待つ。つまり、await を使うと async 関数の実行が一時中断する。(async の外はブロッキングされず同期的に実行される)

エラー処理での利点

async/await を使うと、使わなかった時と比べてエラー処理を行う上での利点がある。
それは、Promise のエラー処理を、catch メソッドではなく try-catch を使って実装できるというもの。
非同期処理を then や catch などのメソッドではなく、同期処理と同じような書き方ができる。

import { readFile, writeFile } from "fs/promises";

// async/awaitを使う
const main = async () => {
  try {
    const fooContent = await readFile("./foo.txt", "utf8");
    await writeFile("src/foo_written.txt", fooContent + fooContent);
    console.log("書き込み完了しました");
  } catch {
    console.log("失敗しました");
  }
};

// 代わりにPromiseチェーンで書く場合
const main2 = () => {
  // わざと失敗させる
  return readFile("./foo.txt", "utf8")
    .then((fooContent) => {
      return writeFile("./foo_written.txt", fooContent + fooContent).then(
        () => {
          console.log("書き込み完了しました");
        }
      );
    })
    .catch(() => {
      console.log("失敗しました");
    });
};

上記の例で分かる通り、Promise チェーンではネストが深くなる一方、async/await ではコードがフラットになり可読性が向上する。

6.7 top-level await

await 式は async 関数の中で使うと書いたが、top-level awaitという機能も登場しており、これによりモジュールのトップレベル(関数外)でも await が使える。
※ 同期関数の中では使えない。

トップレベル await の存在により、非同期処理を必ずしも関数定義せず良くなった。
これによりコードが簡潔化して可読性が向上する。

// 従来の非同期処理
(async function () {
  try {
    const response = await fetch("https://api.example.com/data");
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error("Error fetching data:", error);
  }
})();

// トップレベルawaitを使った場合
// 非同期処理だがasync関数定義していない
try {
  const response = await fetch("https://api.example.com/data");
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.error("Error fetching data:", error);
}

参考

https://gihyo.jp/book/2022/978-4-297-12747-3

https://typescriptbook.jp/

GitHubで編集を提案

Discussion