TypeScript の基礎

2023年11月5日

TypeScript とは

TypeScript は、JavaScript における変数の型に起因した問題を避ける試みのひとつである。JavaScript に型の注釈を導入している。それ以外は概ね JavaScript と同じだが、いくつか拡張されている部分もある。ここでは JavaScript の知識を前提する。

ちなみに、型チェックだけがほしいなら Flow という選択肢もある。

動作環境

TypeScript の主な動作環境としては、Node.js や Deno などがある。ここでは Deno を想定する。

インストール

Linux (Ubuntu 20.04 LTS) の場合、次のように実行する。

$ curl -fsSL https://deno.land/x/install/install.sh | sh

少し待つと、~/.deno 以下にインストールされる。あとはパスを通せばよい。

~/.bashrc

export DENO_INSTALL="$HOME/.deno"
export PATH="$DENO_INSTALL/bin:$PATH"

アップグレードは次のようにすればよい。

$ deno upgrade

Deno の実行

deno と打てば起動する。

Deno 1.37.1
exit using ctrl+d, ctrl+c, or close()
REPL is running with all permissions allowed.
To specify permissions, run `deno repl` with allow flags.
> let a = 1
> a + 1
2

終了するには、Ctrl+D を入力するか、Ctrl+C を 2 回入力するか、close() を入力する。

> close()

コードをファイルに記述しておくこともできる (これをスクリプトという)。拡張子は .ts が使われる。

hello.ts

console.log("Hello");

次のように実行する。

$ deno run hello.ts
Hello

TypeScript をわざわざ使う理由は、型チェックのためである。Deno に型チェックをさせるには、--check を指定する必要がある。

$ deno run --check hello.ts

型チェックだけしたければ、deno check を使う。

$ deno check hello.ts

式

JavaScript と同様、TypeScript のコードも式や構文からなり、式はセミコロン (;) で区切られる。多くの場合セミコロンを省略できるところも同じである。

> a = 1 + 2
3

コメント

/* ... */ や // ... でコメントを入れることができる。

/* 文字列を表示 */
console.log("Hello");

変数の定義

TypeScript の変数は、let で定義する。

> let a = 1

let なしではエラーになる。

変数名は、大文字と小文字が区別される。

var も使える。「巻き上げ」もあるが、型チェックに引っかかる。

console.log(a);
var a = 1;

実行

$ deno check var.ts
...
error: TS2454 [ERROR]: Variable 'a' is used before being assigned.
console.log(a);
            ^

変数への再代入を防ぐ const もある。

> const a = 1
> a = 2
Uncaught TypeError: Assignment to constant variable.

型アノテーション

TypeScript の最大の特徴は、変数に型指定が行えることである。これを型アノテーション (注釈) という。

let a: number = 1;

これに文字列を入れてみると、型チェックで怒られる。

let a: number = 1;
a = "a";

実行

$ deno check var2.ts
...
error: TS2322 [ERROR]: Type 'string' is not assignable to type 'number'.
a = "a";
^

TypeScript は型推論を行うため、上の例では型アノテーションはなくてもよい。

let a = 1;
a = "a";

1 を代入しているため、a は数値型であると正しく推論される。

データ型

データの型は typeof で調べられる (関数ではなく演算子)。

> typeof 1
'number'

any と unknown

any は「何でも型」である。

let a: any = 1;
a = "a";

上のコードは、型チェックに引っかからない。型チェックの意味がなくなるので、any の利用は避ける。

unknown は型の未確定を意味する。型が特定されたときに値を利用できる。

let a: unknown = 1;
let b: number = a + 1;

上の例では、2 行目ではまだ a の型が確定していないと判断され、エラーになる。

error: TS18046 [ERROR]: 'a' is of type 'unknown'.
let b: number = a + 1;

if 文で型を調べたあとであれば、型が特定されたとみなされ、値を使えるようになる。

let a: unknown = 1;
if (typeof a === "number") {
    let b: number = a + 1;
}

リテラル型

const は値自体が型とみなされる。

const b = true;

この場合、b の型は boolean ではなく true と推論される。ただし、typeof で調べるとふつうに boolean と返す。明示的に値を型として指定することもできる。

const b: true = true;

これは、const を外してみれば意味がわかる。

let a: 1 = 1;
a = 2;

これは、次のようなエラーを出す。

error: TS2322 [ERROR]: Type '2' is not assignable to type '1'.
a = 2;
^

数値

数値型 (number) は倍精度浮動小数点数である。

let a: number = 1.2;

文字列

"..." あるいは '...' で文字列 (string) になる。

let a: string = "Hello";

論理値

false と true を取る論理型 (boolean) がある。

let b: boolean = true;

列挙型

JavaScript に列挙型はないが、TypeScript では用意されている。

> enum CatType {
Sabatora,
Chatora,
Mike
}
> let a = CatType.Sabatora
> a
0

> CatType[a]
"Sabatora"

値の型としては数値型である。

数値を指定することもできる。

> enum CatType {
Sabatora = 0,
Chatora = 1,
Mike = 2
}

列挙型は、型アノテーションに使える。

let a: CatType = CatType.sabatora;

値としては数値なのだが、数値を直接入れようとすると型チェックでエラーになる。

シンボル

一意の値を作るためにシンボル (symbol) というものがある。

const SABATORA: symbol = Symbol("sabatora");
let a: symbol = SABATORA;

タプル

TypeScript にはタプルがある。

let a: [number, number] = [1, 2];

タプルは固定長でそれぞれの型が指定された配列の一種である。上の例では、2 要素のタプルでないと型チェックでエラーになる。

? をつけると、型をオプションにすることもできる。

let a: [number, number?] = [1, 2];
a = [1];

上の例では、2 つ目の要素はオプションになっているので、1 要素のタプルでも代入できる。

配列

配列の型指定は次のようにする。

let a: number[] = [1, 2, 3];
let b: Array<string> = ["a", "b"];

number[] でも Array<string> でもどちらの方法で型を指定してもよい (違いはない)。

JavaScript の配列では要素の型の混在が許されていたが、TypeScript の場合は、型が混在していることを指定する必要がある。

let c: (number | string)[] = [1, "2"];

多くの場合は型推論まかせでよいかもしれない。

集合

集合 (Set) の型指定は次のようにする。

let a: Set<number> = new Set([1, 2, 2, 0, 5]);

辞書

辞書 (Map) の型指定は次のようにする。

let a: Map<string, number> = new Map([["A", 1], ["B", 2]]);

オブジェクト

JavaScript のオブジェクトの型についての考え方は、ダックタイピングと呼ばれる。これは、構造的型付けともいう。オブジェクトがどういう構造をもつかというのが型になる。

let prof: {name: string, type: string, age: number}
   {name: "omochi", type: "sabatora", age: 3};

オブジェクトの「形状」を型として指定している。同じ形状のオブジェクトなら、クラスから作られたものでも代入できる。

class Prof {
    name = "";
    type = "";
    age = 0;

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

prof1 = new Prof("annko", "chatora", 4);

上記のクラスを継承したものでも代入できる。

class Prof2 extends Prof {
    food: string[] = [];

    constructor(name: string, type: string, age: number) {
        super(name, type, age);
        this.food = [];
    }
}

prof1 = new Prof2("annko", "chatora", 4);

その逆はできない。

let prof2: Prof2 = new Prof("annko", "chatora", 4);
error: TS2741 [ERROR]: Property 'food' is missing in type 'Prof' but required in type 'Prof2'.
let prof2: Prof2 = new Prof("annko", "chatora", 4);
    ~~~~~

ただ、この場合は food というフィールドの問題なので、「food はあってもなくてもいいよ」という指定をすればいける。

class Prof2 extends Prof {
    food?: string[] = [];

    constructor(name: string, type: string, age: number) {
        super(name, type, age);
        this.food = [];
    }
}

let prof2: Prof2 = new Prof("annko", "chatora", 4);

food? で food がなくても受け入れられるようになる。

空のオブジェクトで型推論をさせると、何も受け付けなくなるわけではなく、逆になんでもありになってしまう。

let prof1 = {};
prof1 = new Prof("annko", "chatora", 4);
prof1 = new Prof2("annko", "chatora", 4);

型チェックの意味がなくなるため、空のオブジェクトを型指定なしに用いるのは避けたほうがよい。

インデックスシグネチャ

フィールドの型が均質で、任意の数になりうるのであれば、インデックスシグネチャというもので前もって予告しておくことができる。

class C {
  [key: string]: string:
  ...
}

key というのは任意の名前で、キーの型は string か number である。上の例では、クラス C のオブジェクトについては、string 型のすべてのキーは string 型の値を持たなければならない、という意味になる。

型エイリアス

type で新しい型を作ることができる。

type Age = number;
type CatType = string;
type Prof = {
    name: string,
    type: CatType,
    age: Age
};

let prof: Prof = {
    name: "omochi",
    type: "sabatora",
    age: 3
};

型の合併 (union) を作ることもできる。

type NumOrStr = number | string;
let a: NumOrStr[] = [1, "2"];

交差 (&) もある。型をまぜこぜするらしい。

type Prof = {
    name: string,
    age: number
};

type CatType = {
    type: string
};

type CatProf = Prof & CatType;
let prof: CatProf = {
    name: "omochi",
    type: "sabatora",
    age: 3
};

型エイリアスの一意性

型エイリアスは型の名前を付け直しているだけなの、問題が起こることがある。

type CatType = string;
type Food = string;

function catTypeCheck(t: CatType) {};
let f: Food = "karikari";
catTypeCheck(f);

上記の最後の関数の呼び出しは、型の名前は異なるにもかかわらず、同じ string なのでエラーにならない。

型チェックが効くようにするには、次のようにする。

type CatType = string & {readonly brand: unique symbol};
type Food = string & {readonly brand: unique symbol};

ただし、これだと Food 型の変数に文字列が入らなくなるので、次のようにする。

let f: Food = "karikari" as Food;

これを型アサーションという。上の場合は、string を Food と解釈しなさい、という指示になる。

undefined、null、void、never

undefined、null の型は、それぞれそのまま undefined、null である (typeof の値自体は、null のほうは "object")。

TypeScript にはこれらの他に void、never と呼ばれているものがある。void は return なく終了する関数の型を意味する (実際は undefined を返すし、typeof の値は当然 "undefined" だけども)。never は、例外でも発生しない限り終了しない関数の型を意味する。

never はまた、すべての型のサブタイプでもある。

プログラムの停止

プDeno では、Deno.exit() でプログラムが停止する。

Deno.exit();

引数として終了コードをとる。

関数

関数における型指定は次のように行う。

function f(x: number, y: number): number {
    return x + y;
}
function g() {
    console.log(f(1, 2));
}

オプション引数

引数に ? を付けることで、その引数をオプションにできる。

function f(x: number, y?: number) {
    ...
}

ただ、ふつうはデフォルト値指定のほうが使いやすいかもしれない。

型エイリアス

関数の型エイリアスは次のようにする。

type NumOp2 = (x: number, y: number) => number;
let f: NumOp2 = (x, y) => x + y;

2 行目の左辺では、引数の型指定をしていない。関数の型から推定できるためである。これを文脈的型付けという。これは、コールバック関数を定義するのに便利である。

function callFunc(callback: NumOp2, x: number, y: number) {
    console.log(callback(x, y));
}

callFunc((x, y) => x + y, 1, 2);

文脈的型付けのおかげで、コールバック関数の型アノテーションを省くことができる。

オーバーロード

TypeScript は、静的型付けを導入したおかげで、関数のオーバーロードが一応、可能になっている。一応、というのは、ここでのオーバーロードは多重定義というより、複数のシグネチャ (関数名と引数型のセット) に対応できる、という意味だからである。実装自体はひとつしかないので、ごちゃまぜの型で頑張ってやるしかない。

function add(x: number, y: number): number;
function add(x: string, y: string): string;
function add(x: number | string, y: number | string): number | string {
    if (typeof x === "number" && typeof y === "number") {
        return x + y;
    } else if (typeof x === "string" && typeof y === "string") {
        return x + y;
    } else {
        throw new Error("Argument type error");
    }
}

console.log(add(1, 2));
console.log(add("a", "b"));

1、2 行目がシグネチャのリストで、3 行目以降が実装である。実装では、全シグネチャを受け取れるような関数を用意することになる。当然、シグネチャのリストなしでも関数はふつうに呼べるが、シグネチャを置いておくと、それに合わない呼び出しは型チェックでエラーにしてくれる。

console.log(add(1, "a"));

エラー

error: TS2769 [ERROR]: No overload matches this call.
  Overload 1 of 2, '(x: number, y: number): number', gave the following error.
    Argument of type 'string' is not assignable to parameter of type 'number'.  Overload 2 of 2, '(x: string, y: string): string', gave the following error.
    Argument of type 'number' is not assignable to parameter of type 'string'.
console.log(add(1, "a"));
            ~~~~~~~~~~~

ところで、number も string も + 演算子を受け付けるので、場合分けをしなくてもよいのでは、と思うかもしれないが、unknwon と事情は同じで、number | string の状態では型が決まっていないので、「+ 演算子は使えないよ」と文句を言われるため、typeof で調べて型を確定してやる必要がある。

型エイリアスでオーバーロードに対応するには、次のようにする。

type Add = {
    (x: number, y: number): number | string;
    (x: string, y: string): number | string;
};

let add: Add = (
    x: number | string,
    y: number | string
) => {
    if (typeof x === "number" && typeof y === "number") {
        return x + y;
    } else if (typeof x === "string" && typeof y === "string") {
        return x + y;
    } else {
        throw new Error("Argument type error");
    }
}

function の方法と異なり、返り値の型は統一しなければならないようである。

ジェネリック

型をパラメータに取るジェネリックというものがある (C++ のテンプレートのようなもの)。ジェネリックで上の add() を実装すると、次のようになる。

function add<T extends number | string>(x: T, y: T): unknown {
    if (typeof x === "number" && typeof y === "number") {
        return x + y;
    } else if (typeof x === "string" && typeof y === "string") {
        return x + y;
    } else {
        throw Error("Argument type error");
    }
}

console.log(add(1, 2));
console.log(add("a", "b"));

T は任意の名前で、型のパラメータである。<T> で型のパラメータを設定するのだが、ここでは extends で型の種類を制限している。戻り値の型は T でもよさそうに思えるが、T は number あるいは string のサブタイプでもよいため、「x + y の型は number あるいは string とは限らない」などと怒られる。しかたないので、unknown としている。

型ガード

曖昧な型に対して、typeof などで分岐させると、TypeScript は型を確定したものとして扱ってくれる。これは型ガードと呼ばれる。この機能を自分で実装したい場合はどうか。たとえば、型を確認する関数を定義したとしても、機能してくれない。

function isNumber(x: unknown): boolean { return typeof x === "number"; }
function isString(x: unknown): boolean { return typeof x === "string"; }

function add<T extends number | string>(x: T, y: T): unknown {
    if (isNumber(x) && isNumber(y)) {
        return x + y;
    } else if (isString(x) && isString(y)) {
        return x + y;
    } else {
        throw Error("Argument type error");
    }
}

これは、「+ が使えない」としてエラーになる。この場合、次のようにする。

function isNumber(x: unknown): x is number { return typeof x === "number"; }
function isString(x: unknown): x is string { return typeof x === "string"; }

x is number などで、関数が型ガードであることを明示している。

クラス

アクセス修飾子

次のようなクラスを考える。

class Prof {
    constructor(name, type, age) {
        this.set_name(name);
        this.set_type(type);
        this.set_age(age);
    }

    set_name(name) { this.name = name; }
    set_type(type) { this.type = type; }
    set_age(age) { this.age = age; }

    show_profile() {
        console.log(`name: ${this.name}`);
        console.log(`type: ${this.type}`);
        console.log(`age: ${this.age}`);
    }
}

これは、次のように書ける。

class Prof {
    constructor(
        private name: string,
        private type: string,
        private age: number
    ) {}
    ...

ここで、private をアクセス修飾子という。ここでは name、type、age をそれぞれ自動的に this に割り当て、アクセスをプライベートなものにする。つまり、クラスの外部では this.name などでアクセスできなくなる。

アクセス修飾子は、private の他に、protected、public がある。protected は、そのクラスのサブクラスからアクセスできるようにする。public はクラスの外からでもアクセスできる。

コンストラクタで指定しない変数については、変数定義のところでアクセス修飾子を付けられる。

class Prof {
    private food = [];
    ...

なにも付けなければ public になる。

抽象クラス

他のクラスのベースにしたいが、インスタンス化はさせたくない場合は、抽象クラスというものが使える。class の前に abstract を付ければよい。

abstract class Prof0 {
    ...

これで、継承はできるが、new でインスタンス化しようとすると怒られるようになる。

自分自身を返す

メソッドで自分自身を返したい場合の型はどうするか。クラス名でもよいが、それだと継承したときにいちいちオーバーライドしなくてはならなくなる。そいうい場合は、this を指定すればよい。

class C {
    ...
    f(x: number): this {
        ...

インターフェイス

オブジェクトの形状を型にするのに、型エイリアスがあった。

type Prof = {
    name: string,
    age: number
};

type CatProf = Prof & {
    type: string
};

let prof: CatProf = {
    name: "omochi",
    type: "sabatora",
    age: 3
};

これと似たもので、インターフェイスというものがある。

interface Prof {
    name: string,
    age: number
}

interface CatProf extends Prof {
    type: string
}

let prof: CatProf = {
    name: "omochi",
    type: "sabatora",
    age: 3
};

インターフェイスを実装したクラスを作ることができる。

class Cat implements CatProf {
    constructor(
        public name: string,
        public type: string,
        public age: number
    ) {}
}

クラスの実装のベースとなるという意味で、インターフェイスと抽象クラスは似ているが、抽象クラスはデフォルトの実装やアクセス制御を提供できるのに対し、インターフェイスはそれができないという違いがある。

モジュール

Deno では ECMAScript modules の形式のモジュールが使える。

標準モジュールなどを利用する場合は、次のようにする。

import { CsvStringifyStream } from "https://deno.land/std@0.205.0/csv/mod.ts";

まず、Deno ではモジュールを URL で指定できる。"std@0.205.0" の形でバージョン指定でき、これによりバージョンを固定できる。バージョン指定は任意だが、推奨されている。パッケージの明示的なインストールは必要ではなく、実行時に自動的にダウンロードされる。この仕組みにより、Node.js でのパッケージの仕組みが簡易化されてている。

Node.js のモジュールも使える。

import { writeFileSync, readFileSync } from "node:fs";

自分で作成したモジュールを使う場合は、以下のようにする。たとえば、以下のようなモジュールを用意したとする。

fibonacci.ts

export default { fibonacci };

function fibonacci(n: number): number {
    if (n === 0) return 0
    else if (n === 1) return 1
    else return fibonacci(n - 2) + fibonacci(n - 1)
}

これを用いるには、次のようにする。

import fibonacci from "./fibonacci.ts";

console.log(fibonacci.fibonacci(10));

コマンドライン引数

Deno をシェルで実行するときにスクリプトと引数を指定した場合、それらは Deno.args で取得できる。

args.js

console.log(Deno.args);

実行

$ deno run --check args.ts 1 2 3
Check file:///home/yuyu/work/typescript/args.ts
[ "1", "2", "3" ]

ファイル

Deno でファイルの読み書きをするには、たとえば次のようにする。

const lines = ["aaa\n", "bbb\n", "ccc"];
Deno.writeFileSync("test.txt", new TextEncoder().encode(lines.join("")));

const lines2 = Deno.readFileSync("test.txt");
console.log(new TextDecoder().decode(lines2));

writeFileSync()、readFileSync() は Uint8Array を扱うため、TextEncoder()、TextDecoder() で変換している。

実行の際には、セキュリティのために書き込み・読み込みに制限がされており、オプションで許可を与える必要がある。

$ deno run --check --allow-write --allow-read file.ts
aaa
bbb
ccc

テキストファイルを直接扱う writeTextFileSync()、readTextFileSync() というのもある。

const lines = ["aaa\n", "bbb\n", "ccc"];
Deno.writeTextFileSync("test.txt", lines.join(""));

const lines2 = Deno.readTextFileSync("test.txt");
console.log(lines2);

1行ずつ処理したい場合は、次のような方法がある。

const lines = ["aaa\n", "bbb\n", "ccc"];

const file = await Deno.open("test.txt", { write: true });
const encoder = new TextEncoder();
const writer = file.writable.getWriter();
for await (const line of lines) {
    writer.write(encoder.encode(line));
}

const file2 = await Deno.open("test.txt", { read: true });
const decoder = new TextDecoder();
for await (const line of file2.readable) {
    console.log(decoder.decode(line));
}

CSV の読み書きには、標準モジュールが使える。

書き出し

import { CsvStringifyStream } from "https://deno.land/std@0.205.0/csv/mod.ts";

const file = await Deno.open("data.csv", { create: true, write: true });
const readable = ReadableStream.from([
    { id: 1, name: "omochi" },
    { id: 2, name: "annko" },
    { id: 3, name: "tamago" },
]);

await readable
    .pipeThrough(new CsvStringifyStream({ columns: ["id", "name"] }))
    .pipeThrough(new TextEncoderStream())
    .pipeTo(file.writable);

読み込み

import { CsvParseStream } from "https://deno.land/std@0.205.0/csv/mod.ts";

const file = await Deno.open("data.csv", { read: true });

const readable = ReadableStream.from(file.readable);
for await (const line of readable
        .pipeThrough(new TextDecoderStream())
        .pipeThrough(new CsvParseStream())) {
    console.log(line);
}

参考

  • Boris Cherny: プログラミング TypeScript, オライリー・ジャパン (2020).
  • Deno で始めるサーバサイド TypeScript 開発, Software Design 2023年3月号.