🌟

TypeScriptの基本

2024/01/18に公開

フロントエンドの勉強を始めたので、その基礎となるTypeScriptについて学んだことをまとめてみました。

TypeScriptとは

まずTypeScriptとはどういう言語なのかということについてです。
TypeScriptはMicrosoftによって開発されているJavaScriptのスーパーセットと言われる言語です。スーパーセットとは特定の対象物に対して既存のものをすべて含んだ上でより機能が拡張されている上位互換となるモノのことです。以下のようにTypeScriptの中にJavaScriptが包含されているというイメージですね。

そのため、すべてのJavaScriptのコードはそのままTypeScriptコードとしても機能します。以下のように全てJavaScriptの記法で書かれたコードもTypeScript上で正常に動作します。

TypeScriptはTypeScriptのまま動作することはできず、JavaScriptにコンパイルされた上で動作します。そのためJavaScriptが動作するブラウザやサーバーなどの環境であればTypeScriptを動作させることができます。

簡単にまとめると、TypeScriptとはJavaScriptと同様の環境で動作する上で、JavaScriptを便利で安全に使用するための機能が追加された上位互換の言語ということです。

TypeScriptが開発された背景

TypeScriptは、JavaScriptでも大規模なアプリケーションを開発しやすくすることを目的に開発されました。JavaScriptは元々大規模な開発を想定した設計ではなく、モジュール性がなくコードの管理が困難であり、型が不明瞭なことに起因するバグも発生しやすく、JavaScriptを用いて大規模開発することはかなり難しかったようです。そこで、特にJavaScriptのスーパーセットであること、モジュール性を持つこと、型システムを導入することを主な目的として開発されたのがTypeScriptです。モジュール性は現在ではJavaScriptにも組み込まれましたが、型システムが導入されていることは今でもTypeScriptの大きな強みとなっています。

なぜTypeScriptが使われるのか

TypeScriptが広く使われる理由には以下のようなものがあげられます。

型安全

TypeScriptは静的型付け言語でコンパイル時に型エラーを検知してくれるため、型に起因するバグを早期に発見することができます。また、型情報がソースコードに書かれるので可読性が上がり、共同開発がしやすくなります。

JavaScriptを学んでいれば学習コストが低い

最初に書いた通り、TypeScriptはJavaScriptのスーパーセットです。そのためJavaScriptを学んだことがあればその知識を活かすことができるため学習コストは低くなります。既存のJavaScriptのコードを書き換える際も、全てのコードを一気に書き換えるのではなく、段階的にTypeScriptに移行するというアプローチを取ることができるため導入も行いやすいです。

トランスパイル

TypeScriptは最新のECMAScript機能をサポートし、それらを古いブラウザでも動作するようにトランスパイルする機能を提供します。これにより、開発者は最新のJavaScript機能を利用しながら、古い環境を含むさまざまな実行環境での互換性を保つことができます。

IDEとツールの強力なサポート

TypeScriptは多くの統合開発環境(IDE)やエディタでサポートされており、自動コード補完、リファクタリング、エラーチェックなど、開発者の生産性を高めるツールを提供します。特にTypeScriptと同じMicrosoft製のVScodeはデフォルトで優れたTypeScriptのサポート機能を備えているため、TypeScriptで開発を進める場合はVScodeが使われることが多くなっています。

基本的な文法

TypeScriptでは変数宣言するときに、その変数にどんな値が代入可能かを指定できます。その指定のことを型注釈(type annotation; 型アノテーション) と言います。
型アノテーションはconst 変数名: 型注釈 = 値;のような形で書くことができます。

const person: string = "ほげ太郎";

このように型注釈を使って明示的に型を宣言することもできますが、TypeScriptには型推論という機能も存在します。

型推論

型推論はコンパイラが型を自動で判別する機能です。型推論を使用すると、型注釈を省略できるので、コードの記述量を減らすことができます。以下は型推論を使った例です。

let person = "ほげ太郎"; // personはstring型になる。

型推論によりpersonはstring型となっているので、別の型の値を代入しようとするとコンパイルエラーが発生します。

このように、型が実行時よりも前のコンパイル時で定まり、型の不整合によるエラーを早期発見できることはTypeScriptを始めとした静的型付け言語の大きなメリットです。

TypeScriptの型

ここからはTypeScriptで使用される型にはどのようなものがあるのかについて書いていこうと思います。TypeScriptの型は大きく分けるとプリミティブ型とオブジェクトの2種類に分けられます

プリミティブ型

プリミティブ型はTypeScriptにおける基本的な型です。オブジェクトは複数の値で構成されることもありますが、プリミティブ型はそれ以上分解できない単一の値です。また、プリミティブ型は一度作成したらその値を直接変更することはできません。以下のようなケースがあるとします。

この場合、一見strの値は"Hello"から"Hello World"に変更されているように見えますが、プリミティブ型の値は変更できないので内部的には"Hello World"という新しい値が作成され、strという変数がその値を指すようになり、"Hello"という値はメモリ上に残ったままになります。ただ、strが新しい値を指すようになったため、元の値"Hello"への参照は失われます。この時点で、"Hello"への参照が他に存在しない場合、その値はガベージコレクションの対象となります。ガベージコレクションは、プログラムによって使用されなくなったメモリ領域を自動的に解放するプロセスです。このプロセスにより、"Hello"という文字列が不要になった場合、そのメモリ領域は最終的にガベージコレクタによって回収されます。

図で表すと以下のような流れになります。

let str = "Hello";の時点では
strは"Hello"という値が格納されたメモリabcを指しています。

str = str + " World";により
strが新しく作成された"Hello World"という値が格納されたメモリefgを指すようになります。

メモリabcに対する参照が無くなったら、そのメモリはガベージコレクションによって解放されます。

あまり意識することはないかもしれませんが、内部的にどのような操作が行われているかは頭の片隅に置いておいてもいいかもしれません。

プリミティブ型には以下の7つの型があります。

型の種類 詳細
string型(文字列) "ほげ太郎"のような文字列。
number型(数値) 0や0.1のような数値。
bigint型(長整数) number型では扱えない大きな整数型。
boolean型(真偽値) trueまたはfalseの真偽値。
undefined型 値が未定義であることを表す型。
null型 値がないことを表す型。
symbol型(シンボル) 一意で不変の値。

オブジェクト

オブジェクトはいくつかの値をまとめたデータのことです。TypeScriptにおいて、プリミティブ型以外の全ての型はオブジェクトであると考えて問題ありません。

オブジェクトの中にはプロパティがコンマ区切りで並べられます。プロパティとはオブジェクト内の一つ一つの値のことです。例の中のperson.nameという構文はオブジェクト内のプロパティの値を得るための構文で、このプロセスをプロパティアクセスと言います。また {}で囲まれている部分をオブジェクトリテラルと言います。ここで利用される値は上の例のname: "ほげ太郎"age: 30のような固定された値だけでなく、以下の例のように変数の値や計算した値も利用することができます。

また、プロパティ名と変数名が同じ場合は以下のように変数名を省略してプロパティ名だけを書く省略記法を用いることができます。

オブジェクトリテラル内ではスプレッド構文という構文を使用することもできます。スプレッド構文は関数呼び出しの引数として...式という構文を用いることができるというものです。スプレッド構文を用いると、オブジェクトの作成時にプロパティを別のオブジェクトからコピーすることができます

スプレッド構文は既存のオブジェクトを拡張した新たなオブジェクトを作りたいときに便利に使うことができます。

型エイリアス

TypeScriptでは、型に名前をつけることができます。独自の名前のついた型を型エイリアスと呼びます。型エイリアスの宣言にはtypeキーワードを使用します。

上の例でいうとScoreは{ math: number; science: number; }型の別名となります。typeを利用すると上のようなオブジェクトだけでなく、任意の型に対して名前を宣言することができます。型エイリアスを利用するメリットには以下のようなものが考えられます。

コードの再利用性と可読性の向上
型エイリアスを使用すると、同じ型定義を複数の場所で再利用でき、コードの可読性が向上します。例えば、特定のオブジェクトの型を何度も書く代わりに、一度定義した型エイリアスを使用することができます。

型の意図の明確化
型エイリアスを使うことで、特定の型が何を表すのかを名前を通じて明確にすることができます。例えば、type UserID = string;と定義することで、単なる文字列型よりも、その型がユーザーIDを表すことが明確になります。

リファクタリングの容易さ
型エイリアスを使用すると、将来的にその型定義を変更する場合に、一箇所の変更で済むため、リファクタリングが容易になります。

このように、型エイリアスを適切に利用することで型システムをより効果的に活用し、メンテナンスしやすく、可読性の高いコードを書くことができます。

インターフェース

インターフェースはtypeとは違った型名を作成する機能です。typeでは任意の型に対して別名を宣言できましたが、インターフェースで扱えるのはオブジェクトだけです。オブジェクトに対しては下の例のようにtypeと同じような使い方が可能です。

以前はtypeが存在せず、インターフェースのみ利用可能だった時期がありましたが、新たな型名の宣言という意味では現在ではほとんどの場合、インターフェースはtypeで代用可能です。現在はtypeとインターフェースでできることが似通ってきているので、どちらを使うのが正解というのはないようですね。

typeとインターフェースの違いについてはこの記事にとてもわかりやすくまとまっていました。
https://zenn.dev/luvmini511/articles/6c6f69481c2d17

関数

TypeScriptの関数はJavaScriptの関数と大きく使い方が変わるようなことはありませんが、ここでも型を利用した書き方ができるようになっています。

上の例のようにTypeScriptの関数の最もベーシックな宣言方法はfunction 関数名(引数): 返り値の型 {中身の構文}という形です。また、以下のように関数式を利用して関数を宣言することもできます。

関数式を利用する場合の基本的な構文はfunction (引数): 返り値の型 {中身の構文}となります。関数宣言とほぼ変わりませんがfunction直後の関数名が無くなっています。関数式はあくまで式なので、利用する場合は基本的に変数に代入する形になります。

アロー関数式を用いる場合は以下のように(引数): 返り値の型 => {中身の構文}の形が基本になります。

関数型

TypeScriptには関数型と呼ばれる関数を表す型も存在します。関数型は以下のようにtype 型名 = (引数名: 引数の型) => 戻り値の型;という形で定義することができます。

type add: = (num1: number, num2: number) => number;

この関数型を用いて以下のような実装を行うことができます。

// 関数型を定義
type add = (num1: number, num2: number) => number;
// resultという変数を宣言し、型として先ほど定義したadd型を指定。
// その後、アロー関数(num1, num2) => num1 + num2をresultに割り当てている。
const result: add = (num1, num2) => num1 + num2;
// 2が返ってくる
console.log(result(1, 1));

const result: add = (num1, num2) => num1 + num2;の部分は本来const result: add = (num1: number, num2: number): number => num1 + num2;と書く必要がありますが、関数型を型注釈に使った場合、関数の実装側の引数と戻り値の型注釈は省略することができるので、その省略記法を用いて書いています。

ジェネリクス

TypeScriptにはジェネリクスという機能があります。ジェネリクスは型を変数のように扱えるようにする機能であり、様々な型で動作する関数を実装することができます。そのため、ジェネリクスを使用しない場合同じような機能を持つ関数でも異なる型ごとに別々の関数を定義する必要がありますが、これらの関数を一つの汎用的な関数に統合し、コードの重複を減らすことができます。

以下ような配列を返す関数を定義する場合、ジェネリクスを利用しない場合だと2つの型それぞれに異なる関数を定義する必要があります。

function getArrayString(items: string[]): string[] {
    return new Array().concat(items);
}

function getArrayNumber(items: number[]): number[] {
    return new Array().concat(items);
}

let stringArray = getArrayString(["apple", "banana"]);
let numberArray = getArrayNumber([1, 2, 3]);

これをジェネリクスを利用することで1つの関数にまとめることができます。

function getArray<T>(items: T[]): T[] {
    return new Array().concat(items);
}

let stringArray = getArray(["apple", "banana"]); // string型の配列
let numberArray = getArray([1, 2, 3]); // number型の配列

ジェネリクスは、上の例のように型引数(例でいう<T>)を用いて定義されます。型引数は、関数が使用される際に具体的な型に置き換えられます。このような、型に柔軟性を持たせるという意味では後ほど紹介するany型を利用することもできますが、any型は型システムを無効化してしまうため、型安全性を失ってしまうという大きなデメリットがあります。そのため、このような場面では型安全性を担保しつつ、様々な型に柔軟に対応するためにジェネリクスが利用されることが多くなっています。

その他の型

ここからは少し特殊な型について書いていきます。

ユニオン型

ユニオン型は型Aまたは型Bのような表現ができる型です。型A | 型Bのように書いて定義することができ、以下の例ではtype Movie = Anime | Action;がそれにあたります。この場合Anime型とAction型のどちらかに当てはまる型はMovie型にも当てはまるということになります。

type Anime = {
  character: string;
};
type Action = {
  title: string;
};

// ユニオン型定義
type Movie = Anime | Action;

const mickey: Movie = {
  character: "Mickey"
}
const superman: Movie = {
  title: "superman"
}

ユニオン型を利用することで、複数の型を許容する柔軟性や、複数の型を個別に扱う必要がなくなり、より簡潔なコードを記述することができるなどのメリットが得られます。

インターセクション型

ユニオン型が型Aまたは型Bのような表現だったのに対し、インターセクション型は型Aかつ型Bを表現する型です。型A & 型Bのような書き方で定義できます。実際には型Aかつ型Bというような条件めいた使い方ではなく、複数のオブジェクト合成し、拡張する用途で使われることが多いです。

type Fruit = {
  name: string;
  color: string;
};
type Sweets = {
  genre: string;
};

// インターセクション型定義
type dessert = Fruit & Sweets;

const pino: dessert = {
  name: "pino",
  color: "brown",
  genre: "ice cream",
};

インターセクション型は3つ以上の型を使って定義することも可能です。

リテラル型

リテラル型はプリミティブ型の特定の値だけを代入可能にする型です。指定した値以外を代入することはできません。以下の場合だとName型にはhogeという文字列しか代入できません。

type Name = "hoge";

// OK
const human: Name = "hoge";

// エラーになる
const human2: Name = "huga";

リテラル型はこのように厳密な値の制約が可能なため、特定の値を持つべき変数に対して誤った値が割り当てられるのを防ぎます。リテラル型単体だと値を1つしか指定できないので、以下のようにユニオン型と組み合わせて使われることも多いです。

// Responseには"Yes"か"No"しか入らない。
type Response = "Yes" | "No";

// StatusCodeには200か404か500しか入らない。
type StatusCode = 200 | 404 | 500;

any型

any型はどんな型でも代入できる型です。TypeScriptの持つ型システムを無効にするため、使用する場合は注意が必要な型になります

let free: any;

// すべてコンパイルエラーにならない
free = 1;
free = "string";
free = { name: "ほげ" }; 

any型にはどんな値を入れることができるだけでなく、どんな型の変数にも代入することができますし、どんな型の引数に渡してもコンパイルエラーにはなりません

このような柔軟すぎるany型ですが、実際にどのようなケースで使用されるのでしょうか。
any型は既存のJavaScriptのコードをTypeScriptに移行する際などに使用されます。最初から厳密に型定義を行おうとして改修が頓挫してしまうケースがあるので、そうならないように一旦any型を活用しながら徐々に移行していくという方法をとることがあるようです。
any型はTypeScriptで最も柔軟な型ですが、型安全性を犠牲にするものなので使用する際は十分に注意して扱うべき型であると言えます。TypeScriptに慣れるまでは基本的に使ってはいけないと思っていたほうがいいかもしれません。

unknown型

TypeScriptには、any型以外にもどんな型でも代入できるunknown型という型も存在します。

let free: unknown;

// any型同様すべてコンパイルエラーにならない
free = 1;
free = "string";
free = { name: "ほげ" }; 

unknown型はその名の通り何が入っているかわからないときに使う型です。ただany型と大きく異なる点はunknown型の値は他の型へ代入できません。また、プロパティへのアクセスやメソッドの呼び出しもできません。要は何でも入るけど、使い方には厳しい制限があるということです。ここが何にでも使うことができたany型との大きな違いです。

const value: unknown = 10;
// 3つともコンパイルエラーになる
const bool: boolean = value;
const str: string = value;
const num: number = value; // 10という数値が入っていてもnumber型にも代入不可

// any型とunknown型には代入できる
const any: any = value;
const unknown: unknown = value; 
let data: unknown;
data = {
    name: "Hoge",
    greet: () => "Hello"
};

// プロパティにアクセスできずエラーになる
console.log(data.name);

// メソッド呼び出しもできないのでエラーになる
console.log(data.greet()); 

ではunknown型の値を利用するにはどうしたらいいのでしょうか。unknown型の値を使うには、型の絞り込みを行う必要があります。型の絞り込みにはtypeofなどを条件式に含んだif文を使います。これは型ガードと呼ばれています。

let data: unknown;
data = {
    name: "Hoge",
    greet: () => "Hello"
};

// 型ガードを使用してプロパティアクセスを行う
if (typeof data === "object" && data !== null && "name" in data) {
    console.log(data.name);
}

// 型ガードを使用してメソッド呼び出しを行う
if (typeof data === "object" && data !== null && "greet" in data && typeof data.greet === "function") {
    console.log(data.greet());
}

ここで使用しているtypeof演算子はJavaScriptおよびTypeScriptで使われる、変数や式の型を特定するための演算子です。TypeScriptではtypeof演算子をifやswitchと併せてつかうと型ガードとして機能します。typeof演算子は、変数や式の実行時の型を文字列として返します。この結果をif文やswitch文で評価することで特定の型に基づいた条件分岐を行うことができ、条件と合致したときにその変数をその型として扱えるようになります

let value: unknown = ...; // 何らかの値

if (typeof value === "string") {
    console.log(value.toUpperCase()); // string型として扱う
} else if (typeof value === "number") {
    console.log(value.toFixed(2)); // number型として扱う
} else {
    console.log("Other type"); // その他の型の場合の処理
}

unknown型は、any型の柔軟性を保ちながらも、型安全性を向上させることができます。そのため型の不明な値を扱う際には、any型よりもunknown型を使用することで型安全性を犠牲にすることなく、動的なデータを安全に操作できます。

まとめ

TypeScriptの基本的な機能について簡単にまとめてみました。TypeScriptはこれ以外にも多くの機能を備えています。フロントエンド、バックエンドどちらでも使える汎用性の高い言語なので今後も継続してキャッチアップしていきたいです。最後までご覧いただきありがとうございました。

参考資料等

https://www.amazon.co.jp/プロを目指す人のためのTypeScript入門-安全なコードの書き方から高度な型の使い方まで-Software-Design-plus/dp/4297127474
https://typescriptbook.jp/
https://zenn.dev/luvmini511/articles/6c6f69481c2d17
https://www.typescriptlang.org/

Discussion