はじめに
1年半くらいTypeScript書いてて、知ったこと・思ったことをまとめてみました。
前半は「入門者向け」、後半は「玄人向け」という感じにしているつもりです。
「これもうちょっとシンプルにならないかな」とか「わざわざこれ書くのだるいわ」などといった"型々"の頭痛が和らげば幸いです。
注意:当記事は茶番が含まれております。茶番アレルギーの方はブラウザバック推奨です。
環境
- typescript:
v4.1.3
- コードを試したい方はこちらの プレイグラウンド がオススメです。
本編
1. type? interface?
TypeScriptには似たような奴らがいらっしゃいます。
私が業務で触れているコードにも、上記お二方が混在しておりかなり困惑しました…
まぁ、まずはちょっと見比べてみましょうか。
type A = {
aaa: string;
bbb: number;
};
interface A {
aaa: string;
bbb: number;
}
…え、何が違うの???
サイ○リア間違い探し検定準2級の私をもってしても、イコールやセミコロンがついてる程度しか見つけられません。
ググって(カンニングして)みたところ、
interfaceは「型の宣言」なのに対し、
typeの正式名称は「型エイリアス」で「無名の型に名前つけてるだけ」とのこと。
// これが「無名の型」
const ab: { a: string; b: string } = {
a: 'a',
b: 'b'
};
// 名前つけてあげれば使いまわせるし便利じゃね?
type AB = { a: string; b: string };
const abTyped: AB = {
a: 'a',
b: 'b'
};
で、結局違いはなんなの?というところなのですが、
こちらの記事でしっかりとまとめていただいてましたのでこちらをご覧ください。
https://qiita.com/sotszk/items/efe32e07e52dce329653
「適材適所」であり、全部こちらを使えばいいってことはないようです。
(逆に、そうじゃなきゃとっくにどちらかが淘汰されているでしょうね)
ただ、「使い勝手の良さ」でいうと個人的にはtype推しですので、以降はtypeの方の話をしていきます。
2. 任意のプロパティとundefined
TypeScriptでは、型に「任意のプロパティ」を持つことができます。「?」をつけるだけ、簡単です。
type AB = {
a: string;
b?: string; // bはいてもいなくてもいいよ(無慈悲)
};
const ab: AB = {
a: 'a',
b: 'b'
};
const a: AB = {
a: 'a'
};
const b = undefined;
const ab2: AB = {
a: 'a',
b
};
上記の例の通り、任意のプロパティbは string | undefined
となっており、「何もない」「undefined」の両方が許可されていますね。
ただ、これにはちょっとだけ落とし穴がありまして…、以下だとどうでしょうか?
type AB = {
a: string;
b: string | undefined;
};
const a: AB = {
a: 'a'
};
const b = undefined;
const ab2: AB = {
a: 'a',
b
};
実はこれ、前者が型エラーになります。「undefined」は許可されていても「何もない」は許可されていません。
TypeScriptでは「何もない」と「undefined」を別物としているわけですね。
3. 型アサーション
TypeScriptではキャストというか、「〇〇型として扱うぞ」的なことができます。(正確にはキャストではないです)
type AB = {
a: string;
b: string;
};
const any1 = {
a: 'a',
b: 'b'
};
const ab1: AB = any1 as AB;
const any2 = {
a: 'a'
};
const ab2: AB = any2 as AB;
const any3 = {};
const ab3: AB = any3 as AB;
上記の通り、 as
を使って型をごまかすことができていますね。
これ見て悪さをしようと思いついた方、あなたとはうまい酒が呑めそうです
type AB = {
a: string;
b: string;
};
const any1 = {
hoge: 123
};
const ab1: AB = any1 as AB;
const any2 = 123;
const ab2: AB = any2 as AB;
const any3 = {
a: 123,
b: 456
};
const ab3: AB = any3 as AB;
へっへっへ、型の抜け道を探すのなんてちょろいもんy……
…は??? as
使えばいくらでもごまかせるんじゃなかったのかよ!なんでエラーになんだよ!
ネタバラシしますと、 as
が使えるのは「サブタイプ」だけです。「サブタイプ」はちょっと言葉で説明しづらいのでここでは深掘りしませんが、エラーになる・ならないのパターンからなんとなく察していただけると助かります。詳しくはWebで(逃)
上記例のおまぬけだったところは、 as any
という最強最悪の抜け道に気づかなかったことでしょう。
as any as AB
(ダブルアサーション)で全てを欺くことができたのですから…
下手に型アサーションをすると「ややこしいだけのJavaScript」と化してしまう可能性が高いので、なるべく避けて通りましょう。「信号機の無い交差点」よりも「信号機の壊れた交差点」の方が遥かに危険なのです。
特に as any
は全方向の信号機を青にするようなものです、 "良い子"も"悪い子"も真似しないでください
// ⭐︎ もし、どうしても型アサーションをしなくてはいけない場面に遭遇したら
type A = {
a: string[];
};
// 🚨 エラーですわ
const a1 = {} as A;
// a1.a はundefinedなので例外エラーが発生するが、型エラーにならないので気づけない
console.log(a1.a.length);
// ✅ せめてもの安全策を取ることをオススメいたしますわ
const a2 = {} as Partial<A>; // Partial はプロパティ全てを「任意」にしてくれます(後で改めて紹介します)
// console.log(a2.a.length); Object is possibly 'undefined'. と型エラーになり
console.log(a2.a?.length); // 例外エラーが発生しないように修正できる
4. anyとunknown
ではanyは悪なのか、と言うと必ずしもそうではないらしいのです。
下記例は 公式 から拝借しました。
function handler(event: Event) {
let element = event as HTMLElement;
// Error: Neither 'Event' nor type 'HTMLElement' is assignable to the other
}
function handler(event: Event) {
let element = event as any as HTMLElement; // Okay!
}
とはいえ、anyはあらゆる型制約を貫通します。
const get1stChar = (str: string) => str[0];
const a: any = 1;
console.log(get1stChar(a)); // 例外エラーになるが、型エラーにはならないため気づけない
関数の型ですら貫通した挙句、関数内ではstringとして認識されるわけです。なんとタチの悪い…
「触らぬanyにエラーなし」これに尽きると思います。
近縁に unknown
というものもありますので、ついでに触れておきましょうか。
ざっくり説明すると、unknownは「anyを安全にしたやつ」です。何が安全なのか、先ほどの例で差し替えて確認します。
const get1stChar = (str: string) => str[0];
const a: unknown = 1;
console.log(get1stChar(a)); // 型エラーになるので気付ける
結果は見ての通りですね。「未知の型を代入できる」という利点を残しつつ、使用時には型をチェックしないとエラーになります。
const get1stChar = (str: string) => str[0];
const a: unknown = 1;
if (typeof a === "string") { // 型をチェック(確定)してあげればOK
console.log(get1stChar(a));
}
どうしても未知の型を受け付けないといけない場面は多々ありますので、その場合は意識してunknownを使うようにすると良いかと思います。
5. TypeGuard
先ほど、 typeof
を使って型チェックをしていましたが、デフォルトの型(numberとかstringとか)以外はどうやってチェックすればいいのでしょう?
まぁ、とりあえず例を見ていきますか。
type A = {
a: string;
};
// この子がTypeGuard君です
const isA = (arg: any): arg is A => arg?.a !== undefined;
const hoge: unknown = { a: 'aaa' };
if (isA(hoge)) {
// true分岐内ではhogeの型はAで確定している
console.log(hoge.a); // 型エラーにならない
} else {
// false分岐内ではhogeの型は未確定
console.log(hoge.a); // Object is of type 'unknown'. 型エラーになる
}
見ての通り、 isA()
を通すと型の判定ができていますね。 arg is A
、英文そのままの意味です。
型アサーションが「決めつける」のに対し、TypeGuardは「判定する」ので比較的安全と言えます。
そう、あくまで「比較的」安全なだけ…。せっかくなんで安全じゃないのも見ておきましょうか…。
type AB = {
a: string;
b: number;
};
// 1. 無条件で通しちゃうやつ(論外)
const isAB1 = (arg: any): arg is AB => true;
const ab1: unknown = {};
if (isAB1(ab1)) {
console.log(ab1.a.length); // 例外エラー
}
// 2. 全プロパティをチェックしていない
const isAB2 = (arg: any): arg is AB => arg?.a !== undefined;
const ab1: unknown = { a: '' };
if (isAB1(ab1)) {
console.log(ab1.b.length); // 例外エラー
}
見ての通り、いくらTypeGuard関数を通しても、こいつ自身が適当なら判定される型も適当になります。
1は論外として、2でもよくない?と思っていた時期が私にもありました。…が、不正データの混入・データ構造の変更等々にフルボッコにされて目が覚めました
使っているDBにもよるかもしれませんが、外から来るデータは基本anyであると捉えた方が良いと思います。(自戒)
6. Enum使っちゃダメなんですか?
他の言語同様、TypeScriptにも Enum
がいますが、あまりいい噂を聞きません。
理由を調べていて、大きく以下の2点がデメリットだと思いました。
- constじゃない: 定数であるはずが再代入が可能であるという矛盾
- 型セーフじゃない: Enumを型として用いた際、定義にない値も代入可能
なるほどこれは…、Enumが嫌われているにも納得せざるを得ませんね…。
「Enumが使えないならどうするのが最適解なのか」は頭のいい人たちが考えてくれてました。
// enum
export enum ABCEnum {
A = 'a',
B = 'b',
C = 'c'
}
// union
export const ABC = {
A: 'a',
B: 'b',
C: 'c'
} as const; // as const を使うことでreadonlyにしている
export type ABC = typeof ABC[keyof typeof ABC]; // "a" | "b" | "c" のunion型になる
おお、Enumでの問題点を全てクリアしています。美しい…
最初、変数と型の名前が一緒なのが気になりましたが、TypeScriptではちゃんと別物として認識されていました(えらい)
それに import { ABC } from './hoge';
と書くだけで両方ともimportできるのも"うまあじ"です。
以下の記事がとても詳しくわかりやすかったので貼らせていただきます。(URLに強い意志を感じる)
7. union型であそぼ
union型はさっきもチラッと出てきましたね。AかBかCか…みたいに、複数の型を取りうるよって時に使います。
type Dice = 1 | 2 | 3 | 4 | 5 | 6;
type Menu = 'coffee' | 'tea' | 'milk';
type ResultError = { code: number; reason: string };
type Result = ResultError | true;
上記の通り、どんな型でも使えます。
それでは、早速あそんでいきましょうか。
type Menu = 'coffee' | 'tea' | 'milk';
type Size = 'short' | 'tall' | 'grande';
type Order = `${Menu}-${Size}`;
const order: Order = 'coffee-short';
上記例は、型に「テンプレートリテラル」を用いることでメニューとサイズを組み合わせた型を表現しています。
ちゃんと型推論されて、予測が出ていることが確認できますね。これは非常に有用でして、jsonやDOM、Objectのkeyなど形式的な文字列ならどこでも活躍が見込めます。
type Menu = 'coffee' | 'tea' | 'milk';
type Size = 'short' | 'tall' | 'grande';
type Order = `${Menu}-${Size}`;
type PriceTable = { [key in Order]: number };
const priceTable: PriceTable = {
"coffee-short": 500,
"tea-short": 490,
...
};
先ほどの例を拡張して価格表を作ることもできるわけです。どうですか…気持ちよくないですか…?
こうやって型制約を加えることで定義漏れ等のヒューマンエラーも防げますし、導入して損はないと思います。
型にハマるのも悪くない…ですよね?…ね?(同調圧力)
8. 関数の型って?
まず、以下の関数定義をご覧ください
const func = (v: string): string => v;
マウスカーソルを当てると、こんな感じに型情報が出てきます。
つまり、先ほどの関数定義はこうなっていたということです。
const func: (v: string) => string = v => v;
まぁ、引数によっては長くなりがちでなので、私自身あまり型を指定することはありませんが…
// 推論にお任せ
const getFullName1 = (firstName, lastName): string => `${lastName} ${firstName}`;
// ちゃんと指定する
const getFullName2: (firstName: string, lastName: string) => string = (firstName, lastName) => `${lastName} ${firstName}`;
それならどこで使うんだってツッコまれそうなので、使い所の例を出しますね。
以下の例では、商品の数量をループで表示しようとしています。
単純に単位を後ろにくっつけるだけなら「単位の文字列」を持つだけでいいですが、複雑なパターンにも対処するために「数量」と「表記を返す関数」のセットを定義するようにしてみました。
当然、「表記を返す関数」は絶体に文字列を返す関数になるように型で限定すべきです。
type Value = {
value: number;
toString: () => string; // 使い所
};
const fish: Value = {
value: 10,
toString: function () {
return `${this.value}[尾]`; // "this" のお話はまた別の機会に…
}
};
const chicken: Value = {
value: 20,
toString: function () {
return `${this.value}[羽]`;
}
};
const pencil: Value = {
value: 26,
toString: function () {
return `${Math.trunc(this.value / 12)}[ダース] + ${this.value % 12}[本]`;
}
};
[fish, chicken, pencil].forEach(v => {
console.log(v.toString());
});
// 10[尾]
// 20[羽]
// 2[ダース] + 2[本]
例のように、「汎用性」を持たせつつ「共通化」したい、となったら大抵このパターンに行き着きます。「関数の型」という概念を覚えておいて損はないのではないでしょうか。
あと、ちゃっかり使われている .forEach()
も引数に関数を受け取るので
ここでもちゃんと関数の型が指定されていますね。関数を引数として渡すようにする(コールバック)ことで汎用性が格段に上がるので、覚えておいて損はない記法だと思います。
9. ジェネリクス
ジェネリクスは「型定義に引数を取りたい」時に使います。言葉だけだとイメージが湧きづらいと思いますので、使用例を見ていただきながら解説を進めていきます。
type Item<Value> = { // 拡張したいところを <> で引数っぽく受け取れる
value: Value;
toString: () => string;
};
const postalCode: Item<string> = { // valueがstring型のItem
value: '123-4567',
toString: function () {
return `〒 ${this.value}`;
}
};
const age: Item<number> = { // valueがnumber型のItem
value: 20,
toString: function () {
return `${this.value} 歳`;
}
};
説明、といっても上記のコメントの通りですね。valueの型を拡張できるようにすることで、それぞれの型用にItem型を別途定義する手間を省くことができます。
それと、これは関数定義等の場合でも同様に使えますのでこちらの例も見ていきましょう。
const divideArray = <T>(array: T[], n: number): T[][] => {
return array.reduce(
(ret: T[][], _: T, i: number) =>
i % n ? ret : [...ret, array.slice(i, i + n)],
[]
);
};
上記は「配列をn個ずつ分割する関数」です。(関数内の処理はあまり気にしないでください)
なんとなくお察しの方もいるかもしれませんが、これは「引数の型によって戻り値の型が決まる」ように拡張性を持たせています。
要は、引数に string[]
を渡せば戻り値は string[][]
に、 number[]
を渡せば number[][]
になるはずだよね?ってことです。早速使用例を見ていきましょう。
type Member = {
name: string;
age: number;
};
const members: Member[] = [
{ name: 'A', age: 10 },
{ name: 'B', age: 20 },
{ name: 'C', age: 15 },
{ name: 'D', age: 17 }
];
const paires = divideArray<Member>(members, 2);
console.log(paires);
// [[{ name: 'A', age: 10 }, { name: 'B', age: 20 }], [{ name: 'C', age: 15 }, { name: 'D', age: 17 }]]
上記はメンバーを二人組に分けている処理です。余りが出るとなぜかダメージを受けるので偶数にしています
型が意図した通りに推測されているか見てみましょうか
うん、大丈夫そうですね〜。ちゃんと Member[][]
が推論されています。
でも、めんどくさがりの人はこう思ったのではないでしょうか、「ジェネリクスで型を指定しなくても引数から勝手に推論してくれればいいのに」と。
はい、もちろんできますよ。そう、TypeScriptならね。
割とTypeScriptは自動的に型を推論してくれますので、じゃんじゃん省略して楽しちゃいましょう!(なお、あえてジェネリクスで型を指定して引数の型をチェックする方が安全な場合もあります)
10. Utility Types
こちらは3章「型アサーション」からの伏線回収です。後回しにしすぎて筆者も忘れかけておりました
デフォルトで用意されている型の便利ツールのようなもの、と説明しました。
「こうしたいときは…そういえば、こんなのあったな」程度に引き出せると "型ライフ" が快適になること受け合い!
とはいえ、これは独自にかみ砕ける物でもないので、良さげな記事と公式へのリンクを貼っておきます。
// TODO: 公開前に消す
これだけだと説明がめんどくさかったのがバレそうだけどまぁいっか
11. Mapped Types
これもちゃっかり7章で登場していましたね…
type Menu = 'coffee' | 'tea' | 'milk';
type Size = 'short' | 'tall' | 'grande';
type Order = `${Menu}-${Size}`
type PriceTable = { [key in Order]: number }; // ここです
const priceTable: PriceTable = {
"coffee-short": 500,
"tea-short": 490,
...
};
7章の例は「プロパティ名は違うけど値の型は同じ」場合です。
これは前章のUtility Typesにある Record
を使うことでも表現できます。ぶっちゃけどっちでもいいので、お好きな方をお使いいただければと存じます。
type PriceTable = Record<Order, number>;
このままでは Mapped Types いらない説が出てしまうので、次はコイツでしかできないパターンを紹介しましょう。
以下の例では顧客情報を帳票化するための定義をする例です。もちろん顧客情報には様々な型があるため、文字列に変換する関数の型もプロパティ毎に違いますよね。こういう時に Mapped Types くんは真価を発揮します。
type FormItem<Value> = {
label: string;
toString: (v: Value) => string;
};
type Form<T> = { [K in keyof T]: FormItem<T[K]> };
type Customer = {
name: string;
age: number;
};
const customerForm: Form<Customer> = {
name: {
label: '氏名',
toString: v => `${v} 様`
},
age: {
label: '年齢',
toString: v => `${v} 歳`
}
};
const customer: Customer = {
name: '山田太郎',
age: 20
};
Object.entries(customer).forEach(([key, v]) => {
// keyがstringとしか推論されないため仕方なくアサーション…なんでなのよ…
const formItem = customerForm[key as keyof Customer] as FormItem<typeof v>;
console.log(`${formItem.label}: ${formItem.toString(v)}`);
});
// "氏名: 山田太郎 様"
// "年齢: 20 歳"
このように、「ある型を元にして別の型を定義したい」時には強い味方になってくれることでしょう(たぶん…)
12. Template Literal Types
これもさっきから何気なく登場しておりましたね。
type Menu = 'coffee' | 'tea' | 'milk';
type Size = 'short' | 'tall' | 'grande';
type Order = `${Menu}-${Size}`; // コイツ
これを拡張して、実際にメニューとサイズから価格を取得する処理を書いてみましょうか。
// ループ処理等で何かと便利なのでEnum的なやつを定義しています
const Menu = ['coffee', 'tea', 'milk'] as const;
type Menu = typeof Menu[number];
const Size = ['short', 'tall', 'grande'] as const;
type Size = typeof Size[number];
type Order = `${Menu}-${Size}`
type PriceTable = Record<Order, number>;
const priceTable: PriceTable = {
"coffee-short": 500,
"tea-short": 490,
...
};
// 価格を取得する関数
const getPrice = (menu: Menu, size: Size): number => {
const order = `${menu}-${size}`;
return priceTable[order];
};
ここに来て急に難易度下がったじゃん、簡単すぎr…
あれ〜???エラーが出てる???
どうも、order
はただのstring型として扱われてしまっていて priceTable
のキーじゃないよって怒られているっぽい。いや、でもさ、 ${menu}-${size}
の結果得られる文字列は絶対 Order
型(priceTable
のキー)になるじゃん?ワケワカンナイヨー(カタカナテイオー)
調べてみたところ、以下の記事がヒットしました。一見理不尽にも思えるこの仕様になった経緯は記事をみていただくとして…
v4.2系以前では as const
を付けることで意図した通りに推論してくれるようになりました。
v4.3.5(プレイグラウンドの最新)では as const
を付ける以外にも、意図的に型指定することでも推論してくれるようになっています。(v4.2系では Order
型に文字列を代入できないとエラーが出る)
ちょいと深みにはまりましたが、エラーは解消されたので価格表を出力してみましょう。
const Menu = ['coffee', 'tea', 'milk'] as const;
type Menu = typeof Menu[number];
const Size = ['short', 'tall', 'grande'] as const;
type Size = typeof Size[number];
type Order = `${Menu}-${Size}`
type PriceTable = Record<Order, number>;
const priceTable: PriceTable = {
"coffee-short": 500,
"tea-short": 490,
...
};
const getPrice = (menu: Menu, size: Size): number => {
const order = `${menu}-${size}` as const;
return priceTable[order];
};
// こうやってループ処理したい場合にEnumっぽい定義は役に立ちます
Menu.forEach(menu => {
const pricies = Size.map(size => `${size}: ¥${getPrice(menu, size)}`).join(' ');
console.log(menu);
console.log(` ${pricies}`);
});
// "coffee"
// " short: ¥500 tall: ¥560 grande: ¥650"
// "tea"
// ...
他にもTemplate Literal Types には、UpperCase(大文字にするやつ)等が用意されていますので、公式ドキュメントをみていただくと面白いかもですよ。
13. Conditional Types
ようやくやってまいりました最終章、Conditional Types くんのご紹介。コイツは型定義におけるif文のようなものです。
おそらく型に凝ってくると、「この型がジェネリクスで来た時はこうしたい!」みたいなことが多々出てきます(個人差あり)
例えば、「戻り値は基本型Aだけど引数に型Bを渡した時だけ戻り値は型Cにしたい関数」とか「プロパティの中から型Aだけを型Bに差し替えたい」とかでしょうか。
なんとなくイメージはつくかと思いますが、書き方が特殊なためコードで説明します。
type Data = {
id: string;
count: number;
isActive: boolean;
};
type NumberToString<T> = { [K in keyof T]: T[K] extends number ? string : T[K] };
const data: NumberToString<Data> = {
id: '1',
count: '3', // number型からstring型に置き換わっている
isActive: true
};
上記は、number型プロパティの型をstring型に置き換える例です。
A extends B ? X : Y
と書くと「型Aが型Bとして扱えるなら型X、そうでなければ型Yとする」という意味合いになります。 extends
は完全一致ではないので以下のような書き方も可能です。
type ValueOf<T> = T extends { value: unknown } ? T['value'] : never;
type A = {
value: string;
hoge: number;
};
type B = {
hoge: number;
}
type AValue = ValueOf<A>; // string
type BValue = ValueOf<B>; // never
型Aは value
を持っているためstring型が取得できますが、持っていない型Bはnever型が返ります。never型は「到達できない型」という意味合いで、関数で return undefined;
するのと同じイメージです。
それと、別解でこんな書き方もできます。
type ValueOf<T> = T extends { value: infer Value } ? Value : never;
突然現れた infer
に困惑されるかもしれませんが、 extends 内で「そこに当てはまる型」に名前を付けられるよってだけです。
最初は感覚が掴みづらいですが、使ってみると「ある型の一部分の型」を取得できる便利さを実感できると思います。
以下は「関数の戻り値の型」を取得する例です。
type FuncReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
const numberToString = (v: number) => v.toString();
const ret: FuncReturnType<typeof numberToString> = numberToString(1); // string
inferの解説の常連さんなので一応紹介しましたが、実は10章で取り扱った Utility Types に ReturnType という名前で最初から入っています …まぁ、あるものは使わせていただくのが吉でしょう。うん。
Conditional Types は玄人向けではありますが、使いこなせば型定義が読みやすくなったり短縮できたりとメリットの多い機能です。是非ともチャレンジしてみてください
終わりに
本当はもっと浅いところで引き返す予定でしたが、ついつい深掘りしてしまいました…
TypeScriptの型はあくまで「コーディングの補助」です。無いと動かないものじゃないけど、ちゃんと定義されていれば予測や型エラーが出るので、入力の手間やタイポ・例外エラーを防げたりと十二分なメリットがあります。
人間はミスをする生き物です。焼け石に水かけたり安全祈願したりぬかに釘打ったり指差し確認したりするよりも、「型をきちんと定義する」ほうが現実的であると断言しましょう!やべ、なんか自信なくなってきた
この記事で皆様の型ライフが少しでも便利になることを祈っております