2021/08/12

りあくと復習(TypeScript編)

@ 酒井悠宇

今日はりあくとで学習したTypeScriptについて復習していきたいと思います。
それではれっつごー

まずは重要語句をざっと復習。

型アノテーション

TypeScriptにおいて、宣言時の変数に型の注釈がつけられることを「型アノテーション(Type Annotation)」と呼ぶ。


これも(プリミティブ型)

let foo: number = 3

これも(リテラル型)

const bar: 3 = 3

どっちも型アノテーション。

javasciptには「暗黙の型変換」というものがあり、以下のような場合に勝手に型を変換して演算を行うという挙動がある。

> const s = '123'; 
> s * 3 
369


TypeScriptを使うことで、このような意図しない挙動を事前に防ぐことができる。

> const s: string = '123'; 
> s * 3 
error


ちなみに+で演算を行う場合は加算ではなく文字列の連結として評価されるので致命的な不具合は起きないと判断してコンパイルエラーにはならない。

> const s = '123'; 
> const n = 456;
> s + n 
'123456'


型推論

TypeScriptでは型アノテーションを省略しても、コンパイラがその文脈からその型を推測できる場合は自動的に補完して解釈してくれる。これを「型推論(Type Inference)」という。


TypeScriptの色んな型

JavaScriptは変数の型付けが動的なだけで、データそのものの型は存在している。TypeScriptの型システムはこれを抱合する形になっている。


プリミティブ型

TypeScriptのプリミティブ型はJavaScriptと共通した次の7種類になる。

  • Boolean 型 ... trueおよびfalseの2つの真偽値を扱うデータ型
  • Number 型 ... 数値を扱うためのデータ型
  • BigInt 型 ... number型では表現できない大きな数値を扱う型
  • String 型 ... 文字列を扱うためのデータ型
  • Symbol 型 ... 「シンボル値」という固有の識別子を明示的に表す型
  • Null 型 ... 何のデータも含まれない状態を明示的に表す型
  • Undefined 型 ... 「未定義」であることを表す型


BigInt型:ES2020未満の環境では、数値は2の53乗しか扱えないが、それ以上の値を扱う時に使える型。ちなみに2の53乗は「9007199254740992(約9000兆)」


配列の型


型名の後に[]をつけるとその型データの配列になる。

const numArr: number[] = [1, 2, 3];

Arrayオブジェクトとして定義する書き方もできる。

const strArr: Array<string> = [ 'one', 'two', 'three' ];


一般的なコーディングルールでは後者よりも前者のようなスタイルが推奨されることが多い


オブジェクトの型


オブジェクトにはobjectという型名があるがObjectオブジェクトはプリミティブ型以外全てのオブジェクトのプロトタイプになっていて、型注釈として使用するには広すぎて意味を成さないため、ほとんど使われない。


これだとあまり意味がない。

const words: object = ['foo', 'bar', 'baz'];


なので、狭義のオブジェクトの型を定義する際は、プロパティのキー名と値の型を明記する形で以下のように型アノテーションを行う。

const red: { rgb: string, opacity: number } = { rgb: 'ff0000', opacity: 1 };


しかしこれを毎回インラインで書くのは大変なので、TypeScriptではオブジェクトの型に名前をつけることができるようになっている。
それは、「インターフェース(Interface)」と呼ばれるもの。以下のように使用する。

interface Color {
  readonly rgb: string;
  opacity: number;
  name?: string;
}

const turquoise: Color = { rgb: '00afcc', opacity: 1 };
turquoise.name = 'Turquoise Blue';
turquoise.rgb = '03c1ff'; // error


・readonly修飾子をつけたプロパティは書き換え不可になる。

readonly rgb: string;

・プロパティの末尾に?をつけると、そのプロパティは省略可能になる。(オプショナル)

name?: string;


プロパティをもっと柔軟に定義する記法として、「インデックスシグネチャ(Index Signature)」というものがある。以下のように使用する。

interface Status {
  level: number;
  maxHP: number;
  maxMP: number;
  [attr: string]: number; //インデックスシグネチャ
}

const myStatus: Status = {
  level: 99,
  maxHP: 999,
  maxMP: 999,
  attack: 999,
  defense: 999,
};


インデックスシグネチャを使用すると、オブジェクトの型定義を柔軟に行うことができる。
myStatusオブジェクトでattackやdefenseプロパティを追加できているのはインデックスシグネチャのおかげ。
ちなみにインデックスシグネチャのキーに使える型は、文字列と数値の2種類のみ。

リテラル型

「literal(文字通りの)」型という意味


リテラル型は以下のようにして使用する。

> let Tom: 'Cat' = 'Cat'; 
> Tom = 'Dog'; //error


演算子|で並べて使用することが多い。

> let Mary: 'Cat' | 'Dog' | 'Rabbit' = 'Cat'; 
> Mary = 'Rabbit'; 
> Mary = 'Parrot'; //error


タプル型

型と順番と個数だけが決まってるものに使われる型。関数の引数などに用いる。


タプル型は以下のように使用する。

const charAttrs: [number, string, boolean] = [1, 'patty', true];


any型

いかなる型の値でも受け付ける型

TypeScriptでこれを使うのは一種の負け。

> let val: any = 100; 
100 
> val = 'buz'; 
> val = null;


any型を使用すると存在しないプロパティのアクセスまで許してしまう。
例えば以下のようなコードでTypeScriiptはコンパイルエラーを吐かない。しかしこのコードを実行するとエラーが出る。

const str = `{ "id": 1, "username": "patty" }`;
const user: any = JSON.parse(str); 
console.log(user.id, user.address.zipCode);

このようなanyの挙動を防ぐためにTypeScriptバージョン3.0からunknownという型が追加された。

unknown型

anyの型安全版。それ自体は何のプロパティもメソッドも持たない型。

以下のコードでは、user.address.zipcodeはもちろん。use.idにもアクセスすることができず、コンパイルに失敗する。

const str = `{"id": 1, "username": "john_doe"}`; 
const user: unknown = JSON.parse(str); 
console.log(user.id, user.address.zipcode); //error


これはまだ未完成のコードであり、その値の型を特定すればコンパイルも通る上、型安全も保証される。
これは「型ガード」と呼ばれる手法。

never型

何者も代入できない型

以下のように使用する。

const greet = (friend: "Serval" | "Caracal" | "Cheetah") => {
  switch (friend) {
    case "Serval":
      return `Hello, ${friend}!`;
    case "Caracal":
      return `Hi, ${friend}!`;
    case "Cheetah":
      return `Hiya, ${friend}!`;
    default: {
      const check: never = friend;
    }
  }
};
console.log(greet("Serval")); // Hello, Serval!

case文の漏れを未然にチェックしている。
途中のcase分にタイポがあればエラーが出る。

関数の型定義


TypeScriptではコンパイラオプションに

"noImplicitAny": true,

を指定していないと引数の型定義がなくても暗黙のうちにany型があてがわれてコンパイルが通ってしまう。

関数宣言での型定義

function add(n: number, m: number): number {
  return n + m;
}
console.log(add(2, 4)); // 6


関数式(function)での型定義

const add = function(n: number, m: number): number {
return n + m;
};
console.log(add(5, 7)); // 12


関数式(アロー関数)での型定義

const add = (n: number, m: number): number => n + m;
console.log(add(8, 1)); // 9

const hello = (): void => { //何も返さない関数の戻り値型はvoidになる。
  console.log("Hello!");
};
hello(); // Hello!


interfaceを使用して引数と戻り値をまとめて定義することもできる。(関数の型を呼び出し可能オブジェクト(Callable Object)として定義する)

interface NumOp {
  (n: number, m: number): number;
}

const add: NumOp = function (n, m) {
  return n + m;
};
console.log(add(1, 2)); // 3

const subtract: NumOp = (n, m) => n - m;
console.log(subtract(7, 2)); // 5


関数の定義をアロー型アノテーションによってインラインで行ったもの(読みにくい!)

const add: (n: number, m: number) => number = function (n, m) {
  return n + m;
};
console.log(add(3, 7)); // 10

const subtract: (n: number, m: number) => number = (n, m) => n - m;
console.log(subtract(10, 8)); // 2


関数の型定義をインラインで書くことはあまりせず、やるとしたらinterfaceで呼び出し可能オブジェクトとしての型を適用する方法を使う。
Reactでコンポーネントを関数で定義するときは、引数と戻り値の型をそれぞれ定義するのではなく、Reat.FunctionComponent<p>として抵抗されている関数の型を使うことが多い。


関数の型宣言にジェネリクスを使う方法。
任意の型を<>によって引数として渡すことで、その関数の引数や戻り値の型に適用できるようになっている。これは「型引数(Type Parameter)」 と呼ばれる。

const toArray = <T>(arg1: T, arg2: T): T[] => [arg1, arg2];
toArray(8, 3); // [ 8, 3 ] //型推論によってTがnumberになっている。
toArray('foo', 'bar'); // [ 'foo', 'bar' ] //型推論によってTがstringになっている。
toArray(5, 'bar'); //error


.tsx内で関数の型定義をジェネリクス+アロー型アノテーションでやるとエラーが起きる問題。


番外編です。ちょっと変な挙動があったので共有しておきます。

拡張子.tsxのファイルで、ジェネリクスを使う関数の型定義をアロー型で試してみたところエラーが出ました。
ジェネリクス<T>がjsx要素であると判定されているようです。

ちなみにもちろん拡張子.tsのファイル内では上記のエラーは出ませんでした。

functionだと普通にいけました。(.tsx内)


型エイリアスでもいけました。(.tsx内)


アロー型の関数型定義はジェネリクス無しだと普通に使えます。(.tsx内)


なんか拡張子.tsxのファイル内で、アロー型の関数定義をジェネリクスを使ってやるとジェネリクスがjsxの要素であると判定を受けてエラーが出るみたいです。
なんか設定とかで通るようにできるのかな?
まあなんか.tsxではこの型定義が使えないっぽいことがわかったので復習進めまーす。

データの型に束縛されないよう型を抽象化してコードの再利用性を向上させつつ、 静的型付け言語の持つ型安全性を維持するプログラミング手法を『ジェネリックプログラミング (Generic Programming)』と呼ぶ。そして型引数を用いて表現するデータ構造のことを『ジェネリク ス(Generics)』という。


TypeScript でも可変長引数が使えるみたいです。

type Array = {
  <T>(...args: T[]): T[];
};

const toArrayVariably: Array = (...args) => [...args];

toArrayVariably(1, 2, 3, 4, 5); //[ 1, 2, 3, 4, 5 ]


TypeScriptでのクラスの扱い


TypeScriptは2012年に生まれた当初からクラスを備えていた。その後JavaScriptにもES2015にクラス構文が導入されたが、厳格な型を持つTypeScriptのクラスとJavaScriptのクラスは、一見似ているようで異なる部分がそこそこある。


TypeScriptのクラス構文

class Rectangle {
  readonly name = "rectangle";
  sideA: number;
  sideB: number;

  constructor(sideA: number, sideB: number) {
    this.sideA = sideA;
    this.sideB = sideB;
  }

  getArea = (): number => this.sideA * this.sideB;
}


ES2015 のクラス構文ではコンストラクタ内にその記述があることでメンバー変数が生成されるの で、特に変数の定義をする必要はない。いっぽうTypeScriptではメンバー変数は、クラスの最初でその宣言をしておく必要がある


つまりTypeScriptではクラスの最初でメンバ変数の型を定義しておかなければならないということ。

インスタンス変数(メンバ変数)とはインスタンス内で使える変数のこと。「this」演算子を使用して宣言する。


最初にある以下の記述は、「プロパティ初期化子(Properth Initializer)」という機能で、コンストラクタに引数がないクラスでは、インスタンスの初期化をこれだけで済ませてコンストラクタを省略することもできる。
readonly name = "rectangle";
宣言時に readonly 修飾子を付けることで、そのメンバー変数を変更不可にすることもできる


他にも「アクセス修飾子」というものがあり、これを宣言時につけることで、そのメンバーのアクセスをコントロールできる。

アクセス修飾子

  • public ... 自クラス、子クラス、インスタンス全てからアクセス可能。デフォルトでは全てのメンバーがこのpublicになる。
  • protected ... 自クラス及び子クラスからアクセス可能。インスタンスからはアクセス不可。
  • private ... 自クラスからのみアクセス可能。子クラス及びインスタンスからはアクセス不可。


TypeScript ではたしかに、各クラスをうまいことカ プセル化して、継承をつなげていくことが可能であるが、積極的にやるべきかどうかというのとはまた話が別。なぜなら、今日のオブジェクト指向プログラミングでは、『継承よりも合成(Compostion over Inheritance)』のスタイルが優勢になってるから。


親クラス

class Rectangle {
  readonly name = "rectangle";
  sideA: number;
  sideB: number;
  constructor(sideA: number, sideB: number) {
    this.sideA = sideA;
    this.sideB = sideB;
  }
  getArea = (): number => this.sideA * this.sideB;
}


親クラスを継承したクラス

class Square extends Rectangle {
  readonly name = "square";
  side: number;
  constructor(side: number) {
    super(side, side);
  }
}


継承で記述する方が抽象度が高くコード量が少ないので、優れているように見えるかもしれないが、この時Squareは暗黙のうちに不必要な公開メンバー変数sideAとsideBまで継承してしまっていて、それが後々バグを生む芽になりかねない。また、getArea()メソッドが完全に共有されてしまっているため、親クラスの実装を不用意に変更できず、継続的なコード改善の障害になる。つまり保守性が悪い。


確かに。繋がりが強いから抽象化して書くことができるけど、不必要なものまで受け取ってると危ないのか...

継承は子クラスが親クラスに強く依存するため、親クラスの変更が子孫のクラスたちに及ぼす影 響を予測するのは難しく、テストを書いていたとしてもすべてのパターンを網羅できるとは限らない。そもそも親クラスと子クラスは名前空間を完全に共有しているせいで、責任の境界線があいまいになりがち。それが設計を難しくさせてるし、いざ不具合が起こったときにも、どこにその原因があるのか突き止めるのが難しい。


なるほど。継承だと考えることが多すぎて詰むと。

実はこの継承のコードでは、これを動かすために親クラス Rectangle の name プロパティから readonly 修飾子を削除する必要がある。これが継承を前提とした際の設計の難しさだし、そこから手戻りで親クラスの実装を変更してしまう羽目になり、バグを生みがちな原因でもある。


子を動かすために親を変更しないといけない。でもその親は他のクラスに継承されている可能性があるからそこの挙動も考えないといけない...

親クラスに合成したクラス

class Square {
  readonly name = "square";
  side: number;
  constructor(side: number) {
    this.side = side;
  }
  getArea = (): number => new Rectangle(this.side, this.side).getArea();
}


いっぽう合成の例では、Rectangle クラスを独立したただの部品として扱ってる。開発者は Rectangle クラス内部の実装を知る必要はなく、ただそのAPIとしての入力と出力の仕様を知ってさえいればいい。そして依存がないゆえに Rectangle クラスの内部の変更に Square クラスが影響されることはない。個々のモジュールの独立性が高く、より保守性にすぐれたコードであると言える。


なるほど。確かにこの例だとRectangleクラスのgetArea()メソッドがどのような挙動であるかを知ってさえいればあとは依存がないから考えなくっていいてことか。

2010 年以降に登場した設計の新しい言語である Go や Rust なんかでは、実装を伴った継承がそもそも存在しない。現代は継承そのものを避けるべきという認識が開発者の間に広まってきている。React でもコンポーネントをクラスで作成するときは、継承を避けるよう公式ドキュメントに書かれてる


全然知らなかった。継承より合成。継承より合成。継承より合成。よし覚えた。

クラスの2つの顔


クラスを継承すれば、その親クラスのメンバー定義に縛られる為、クラスは、それ自体が実装でありながら、型定義としての側面も併せ持っている。

ふむふむ

そのクラスの型を抽象化して定義する方法が、TypeScript には 2 つある。 一つ目はabstract 修飾子を用いて抽象クラスを定義する方法。二つ目はインターフェースを使う方法。

何言っとるかよくわからん。

抽象クラス:それ自身がインスタンスを生成できず、継承されることを前提としたクラス

なるほど。

1つ目の方法はあまり好ましくない。なぜなら、抽象クラスはその定義に実装を含むことができてしまうから。避けるべきは実装を伴った継承で、実装を伴わずに型だけを適用したい。
そこで2つ目の方法、インターフェースを使う。先程の Rectangle クラスから型を抽出して、それを適用する形で書き直してみよう。


interface Shape {
  readonly name: string;
  getArea: () => number;
}

interface Quadrangle {
  sideA: number;
  sideB?: number;
  sideC?: number;
  sideD?: number;
}

class Rectangle implements Shape, Quadrangle {
  readonly name = "rectangle";
  sideA: number;
  sideB: number;
  constructor(sideA: number, sideB: number) {
    this.sideA = sideA;
    this.sideB = sideB;
  }
  getArea = (): number => this.sideA * this.sideB;
}

implementsでインターフェースをコンマで並べると複数のインターフェースを適用することができる。

核心に迫っていくために以下のサンプルコードをご覧ください。

おけい。

class Point {
  x: number = 0;
  y: number = 0;
}
const pointA = new Point();
const pointB: Point = { x: 2, y: 4 };
interface Point3d extends Point {
  z: number = 0;
}
const pointC: Point3d = { x: 5, y: 5, z: 10 };


クラスとして定義されたはずの Point がインターフェースとして扱われていますね。

ほんとや。

実は TypeScript でクラスを定義すると、実際には 2 つの宣言が同時に実行されています。ひとつはそのクラスインスタンスのインターフェース型宣言。もうひとつはコンストラクタ関数の宣言。 だから Point は型のコンテキスト(文脈)においてはインターフェースとして扱われ、通常のコンテキストではコンストラクタ関数として扱われるというわけです。

ほぉ〜!

クラスは TypeScript にとって、型でもあり関数でもあるという二重の存在といえますね。ちなみに、定義したクラスからモジュールとして型のインターフェースだけ をインポート/エクスポートするなんてこともできます。

suge~!

はい、というわけで今日はTypeScriptを一通り復習してみました。曖昧だったところが少し曖昧じゃなくなった気がしてます!
最後までごらんいただきありがとうございました!最後にまとめを置いておきまーす!

まとめ

  • オブジェクトの型はプロパティのキー名と値の型を明記する形でアノテーションを行うことができるが、毎回インラインで書くのは大変なので、interfaceや型エイリアスを使用して定義する。
  • [attr: string]: number;のようにインデックスシグネチャを使用することで、任意のキーのプロパティを複数個追加することができる。オブジェクトのキーに使える型はnumber又はstringのみ。
  • 型と順番と個数だけが決まってる関数の引数とかはタプル型を使うことがある。
  • anyの型安全版としてunknown型が生まれた。unknown型のコードはまだ未完成であるが、値の型を特定すればコンパイルも通る上、型安全も保証される「型ガード」
  • never型は何も代入できない型。case文の漏れを未然にチェックするときとかに使う。
  • 拡張子.tsxのファイルでアロー型の関数宣言をジェネリクスを使ってやるとジェネリクスがjsxの構文であると判断されてエラーが出る。何でかはしらん!
  • TypeScriptでも(...args: number[])みたいな可変超引数が使える。
  • TypeScriptのクラス構文ではJavaScriptと違って最初にメンバ変数の型を定義しておかなければならない。
  • オブジェクト指向プログラミングでは「継承よりも合成」のスタイルが優勢になっている。なぜなら、クラスの継承はいざ不具合が起こったときにも、どこにその原因があるのか突き止めるのが難しいから。合成なら親クラスのを独立した部品として扱うことができるので、依存がなく保守性に優れている。
  • クラスには2つの顔がある。「型定義でもあり、関数でもある」ということ。
  • TypeScript でクラスを定義すると、実際には 2 つの宣言が同時に実行される。ひとつはそのクラスインスタンスのインターフェース型宣言。もうひとつはコンストラクタ関数の宣言。 だからクラスは型のコンテキスト(文脈)においてはインターフェースとして扱われ、通常のコンテキストではコンストラクタ関数として扱われる。
© 2021 powerd by UnReact