🫐

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

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/

Discussion