はじめに
こんにちは、フロントエンドエンジニアのall-userです!
これはdelyアドベントカレンダー9日目の記事です。
昨日はプロダクトデザイナーのkassyさんプレゼンツ「デザインとエンジニアリングをつなぐために重要な3つのこと」でした。
開発現場でも直面することの多いコミュニケーションの問題と、それに対して心掛けていることについて書かれていて、うんうんとうなずきながら読んでしまいました。ぜひこちらもご覧ください!
それでは、TypeScriptを使ったクラシルのフロントエンド開発の中で、思わずへ〜となったトリビアたちを紹介したいと思います。
目次
- はじめに
- 目次
- 1. 循環依存のエラーを回避する方法
- 2. 自身を循環参照する型の書き方
- 3. document.querySelectorは引数の型から返り値のElement型を判定してくれる
- 4. 存在しないプロパティの存在チェックをする方法
- 5. String EnumsとString Literal Union Typesの使い分け
- 6. switch文を型安全に書く方法
- 7. 高階関数の型から返り値の型を取り出す方法
- 8. ネストした配列の中身の型を取り出す方法
- 9. プロパティを持つ関数オブジェクトの型の書き方
- 10. オーバーロードの型のみを定義する方法
- さいごに
1. 循環依存のエラーを回避する方法
ES ModulesやCommon JSにおいて、あるファイルから別のファイル、そのファイルから更に別のファイルへと依存を辿っていった際、その依存グラフの中に再度自身のファイルが登場してしまうような依存関係を循環依存と呼びます。
特定のケースではこの循環依存が原因で実行時エラーが発生してしまう場合があります。
また、循環依存はTypeScriptの型の上では問題にならないため、コンパイルが通ってしまう点にも注意が必要です。
. ├── a.ts ├── b.ts └── index.ts
// a.ts import { b } from './b'; class A { getB() { return b; } } export const a = new A(); // b.ts import { a } from './a'; type A = typeof a; class B { a: A; constructor(a: A) { this.a = a; } } export const b = new B(a); // index.ts import { a } from './a'; console.log(a); console.log(a.getB()); console.log(a === a.getB().a); console.log(a.getB() === a.getB().a.getB());
このファイルをwebpackでバンドルして実行すると以下のようなエラーが発生します。
Uncaught TypeError: Cannot read property 'getB' of undefined
どうしてエラーになってしまうのか?
index.ts
がa
をimportしますa
はgetB
というメソッドの中でb
を参照するため、b
をimportしますb
はコンストラクタの引数にa
を受け取るため、a
をimportします- この時の
a.ts
モジュールは初期化が完了していないため空のオブジェクトになります a.ts
が空のオブジェクトのためnamed exportされたa
はundefined
になります
- この時の
a
はundefined
のためnew B(a)
はnew B(undefined)
となりますa.getB().a
はundefined
になりますa.getB().a.getB()
はundefined
に対する存在しないメソッド呼び出しになるためエラーが発生します
エラーを回避する方法
internal.ts
というファイルを作り、依存するファイルのexportを全て一つにまとめます。
. ├── a.ts ├── b.ts ├── index.ts └── internal.ts
// internal.ts export * from './a'; export * from './b';
a.ts
, b.ts
をそれぞれ直接importするのではなく、internal.ts
からimportするように変更します。
// a.ts import { b } from './internal'; class A { getB() { return b; } } export const a = new A(); // b.ts import { a } from './internal'; type A = typeof a; class B { a: A; constructor(a: A) { this.a = a; } } export const b = new B(a); // index.ts import { a } from './internal'; console.log(a); console.log(a.getB()); console.log(a === a.getB().a); console.log(a.getB() === a.getB().a.getB());
webpackでビルドして実行してみると今度はエラーが起きません。
どうしてエラーにならないのか?
このトリビアは以下の記事で紹介されていました。
internal.ts
を経由することで、b.ts
がa
をimportするタイミングでinternal.ts
のa
が事前に初期化されているようにうまいこと調整されています。
index.ts
がa
をinternal.ts
からimportしますinternal.ts
がa.ts
をexportしますa
はgetB
というメソッドの中でb
を参照するため、b
をinternal.ts
からimportします- この時の
internal.ts
は初期化が完了していないため空のオブジェクトになります internal.ts
が空のオブジェクトのためnamed exportされたb
はundefined
になりますb
はgetB
が呼ばれるまで遅延評価されるため、この時点でのb
のundefined
は評価されません
- この時の
internal.ts
がb.ts
をexportしますb
はコンストラクタの引数にa
を受け取るため、a
をimportします- この時の
internal.ts
モジュールには、すでにexportされたa
が存在します
- この時の
a
を参照することができるため、new B(a)
は成功しますa.getB().a
はa
と同一になりますa.getB().a.getB()
はb
を返します
全てのエラーを回避できるわけではない
上記の仕組みを考えてみると、internal.ts
が空のオブジェクトであることが許容できないケースではエラーを回避できないことが分かります。
index.ts
とinternal.ts
を以下のように書き換え、new B(a)
されるタイミングでinternal.ts
にa
が初期化されていない状況を意図的に作ると、やはりエラーが発生します。
// index.ts import { b } from './internal'; console.log(b); // B {a: undefined} console.log(b.a); // undefined console.log(b.a.getB()); // Uncaught TypeError: Cannot read property 'getB' of undefined // internal.ts export * from './b'; // bを先に読み込む export * from './a'; // b.ts import { a } from './internal'; type A = typeof a; class B { a: A; constructor(a: A) { this.a = a; } } export const b = new B(a); // internal.ts が空オブジェクトのため a が undefined になる
Parcel, Rollup, .mjsでも有効
全ての方法で循環依存のエラーを回避できました🙌
Parcel, Rollupでビルドした場合でも、.mjs
拡張子のファイルを直接ブラウザで読み込んだ場合でも、webpackと同様にinternal.ts
を経由することで循環依存を解決することができます。
ひとつだけ異なる点として、.mjs
拡張子のファイルを直接Chromeで読み込んだ場合、初期化が完了していないexportはundefined
になるのではなく、その値を評価した時点でエラーになります。
vuex-smart-moduleのModuleをinternal.tsでまとめる
クラシルではTypeScript + Vue + Vuexを使っていて、当初Vuexの型は自前で書いていましたが、現在はvuex-smart-moduleというライブラリを導入し、快適に型の恩恵を受けられるようになりました。
通常VuexではActionのtypeを文字列ベースで指定してdispatchするため、Vuex Module同士の静的な依存関係は生まれません。(だから型付けも難しいのですが)
vuex-smart-moduleでは、state、getters、dispatch、commitなどを参照する先の(vuex-smart-moduleが提供する)Moduleクラスインスタンスをimportし、そのモジュールのcontextと呼ばれるオブジェクトにStoreの実体をDIすることで、そのModuleのactionsなどを呼び出す仕組みになっています。
型安全にVuexを利用できて、記述もスッキリして良いことばかりなのですが、Module同士の依存関係がimportを通じて行われるために循環依存が発生しやすくなる、という側面があります。(好ましくない依存を見つけやすいというメリットでもあります)
このトリビアを使い、Moduleのインポートをすべてinternal.ts
経由にすると、循環依存のエラーを回避することができます。
これTypeScriptのトリビア?
このトリビアはTypeScriptというよりES Modulesやバンドルツールのトリビアだなと今更ながら気づいてしました。
先行きが不安ですがお付き合いください😂
2. 自身を循環参照する型の書き方
このトリビアはTypeScript 3.7でRecursive Type Aliasesが利用できるようになったおかげで、現在は必要なくなったようです。
TypeScript、本当にすばらしいですね。
ということで、すでに使い所がなくなったトリビアですが、めげずに紹介していきたいと思います。
自身を参照する型をtypeで定義するとエラーになる(TS3.6まで)
type Json = | string | number | boolean | null | { [property: string]: Json } | Json[];
interfaceだとエラーにならない
interfaceでは型の解決が遅延されるらしくエラーになりません。
type Json = | string | number | boolean | null | JsonObject | JsonArray; interface JsonObject { [property: string]: Json; } interface JsonArray extends Array<Json> {}
どうしてエラーにならないのか?
このトリビアは以下のstack overflowの回答で知りました。
また、エラーにならない理由についてのissueコメントのリンクが貼られています。
interfaceではプロパティの型と継承元の型の解決が遅延されるので、このような回避策が実現できるようです。
3. document.querySelectorは引数の型から返り値のElement型を判定してくれる
document.querySelector
の返り値の型は、引数の型によってどの要素なのかを判定してくれます。
どんな型でも判定できるわけではなく、セレクタが単一の要素名(タグ名)の時のみ有効です。
どうやって判定しているのか?
querySelector
の型定義を覗いてみると、オーバーロードされた定義がありました。
HTMLElementTagNameMap
というinterfaceに全ての要素名と対応する要素の型がマッピングされていて、キーに一致するString Literal型を引数に取ると、返り値の型が確定するという仕組みになっているようです。
もちろん、document.querySelectorAll
にも対応してくれています。
素敵です。
4. 存在しないプロパティの存在チェックをする方法
存在しないプロパティに対し、ドットアクセスや添字でアクセスしようとするとコンパイルエラーになってしまいます。
type SomeObject = | { a: string; } | { b: number; } | { c: boolean; }; declare const someObj: SomeObject; if (someObj.c) { // エラーになる console.log(someObj); }
in
演算子を使用するとプロパティの有無をチェックすることができます。
if ('c' in someObj) { console.log(someObj); // someObj は { c: boolean }型 }
ちゃんと型の絞り込みもできます。
素敵です。
5. String EnumsとString Literal Union Typesの使い分け
String EnumsはString型で定義できるEnumsです。
enum EnumSomething { a = 'a', b = 'b', c = 'c', d = 'd' } declare const someVar: EnumSomething; if (someVar !== EnumSomething.a) { // someVar の型は EnumSomething.a | EnumSomething.b | EnumSomething.c に絞り込まれる } switch (someVar) { case EnumSomething.a: case EnumSomething.b: case EnumSomething.c: // someVar の型は EnumSomething.b | EnumSomething.c | EnumSomething.d に絞り込まれる break; default: // // someVar の型は EnumSomething.d に絞り込まれる break; }
String Enumsは多くの場合、String Literal Union Typesで置き換えが可能です。
type LiteralSomething = 'a' | 'b' | 'c' | 'd'; declare const someVar: LiteralSomething; if (someVar !== 'a') { // someVar の型は "b" | "c" | "d" に絞り込まれる } switch (someVar) { case 'a': case 'b': case 'c': // someVar の型は "a" | "b" | "c" に絞り込まれる break; default: // someVar の型は "d" に絞り込まれる break; }
Enumsはコンパイル後にオブジェクトが生成されますが、String LIteral Union TypesはTSの世界で完結しているため、コンパイル後のコードには残りません。(Enumsもconstを付けるとオブジェクトの生成を抑止できます)
可読性の面でも優れていますし、VS Codeのオートコンプリートも働くので、基本的にはString Literal Union Typesを使うと幸せになれます。
String Enumsに適したケース
そんな便利なString Literal Union Typesですが、String Enumsが適しているケースもあります。
先程の例ではsomeVar
がLiteralSomething
型なので、オートコンプリートが効き、型の安全性も担保されていましたが、比較対象がstring
型の場合、オートコンプリートが効かず、型の安全性も担保されません。
declare const mightA: string; if (mightA === EnumSomething.a) { // Enumsの場合オートコンプリートが効く & EnumSomething型であることが担保される // doSomething(); } if (mightA === 'a') { // String Literalの場合オートコンプリートが効かない & LiteralSomething型であることが担保されない // doSomething(); }
タイプミスでaa
と打ってしまった場合、Enumsではエラーがでますが、String Literalでは比較対象がstring型のため、コンパイルが通ります。
String Literal Union Typesを期待している比較対象の型がstring型になってしまう場面の一例として、ライブラリの型定義がstring型だけど、利用する側では特定の型に限定したい、というような場面があります。
たとえば、クラシルではvue-routerのroute.name
の定義にString Enumsを使用していますが、これはvue-routerのライブラリ側が期待するroute.name
のstring型に対し、型安全に自分たちで定義した型を渡せるようにするためです。
export enum SomethingRoutes { foo = 'foo', bar = 'bar', baz = 'baz' } const routes = [ { name: SomethingRoutes.foo, path: '/foo', component: Foo }, { name: SomethingRoutes.bar, path: '/bar', component: Bar }, { name: SomethingRoutes.baz, path: '/baz', component: Baz } ];
アプリケーション側ではroute.name
に渡す型を、routes
で定義したString LIteral型のみに限定したいわけですが、vue-routerのAPIではroute.name
はstringで定義されています。
Enumsを使うことでstring型を期待するAPIに対し、アプリケーション側が期待する型を安全に渡すことができます。
this.$router.push({ name: SomethingRoutes.foo // name は string型を受け取るが,SomethingRoutes型であることが担保される });
6. switch文を型安全に書く方法
switch文を使うと型の絞り込みを行うことができますが、そこに登場するcase節が、取り得る全ての値を抜け漏れなく記述できているかを、型で検査する方法です。
type SomeType = 'a' | 'b' | 'c' | 'd'; declare const doA: () => void; declare const doB: () => void; declare const doC: () => void; declare const doD: () => void; const someFunc = (value: SomeType) => { switch (value) { case 'a': return doA(); case 'b': return doB(); case 'c': return doC(); case 'd': return doD(); default: { const _: never = value; console.error(`${_} is unexpected value`); } } };
取り得る全てのcase節が記述され、適切にbreakやreturnが行われている時、default節ではvalueの型がnever型になります。
このことを利用し、default節でnever型の変数にvalueを代入しておくことで、default節でnever型以外の型が代入される(case節の記述が漏れている)ケースを防ぐことができます。
試しにcase 'd':
をコメントアウトすると、never型の_
に'd'
型のvalueを代入しようとしてエラーになります。
7. 高階関数の型から返り値の型を取り出す方法
Vuexのgettersのように、ある値を返す関数、もしくはある値を返す関数を返す関数(高階関数)を受け取るようなAPIは様々なフレームワークでよく見かけるパターンです。
このような関数かどうか分からない、また、いくつ多段にネストしているか分からない型から、最終的な戻り値の型を取り出す方法です。
まずは高階関数もしくはその返り値を表す型を定義します。
type HOFOrValue<T> = (...args: any) => HOFOrValue<T> | T;
次に高階関数から返り値の型を取り出してみます。
type HOFReturnType<T extends HOFOrValue<any>> = T extends HOFOrValue<infer U> ? U : never;
- 型パラメータとして高階関数
T
型を受け取ります(T extends HOFOrValue<any>
) T
をHOFOrValue<any>
型に代入可能かを検査します- その際に
infer U
でHOFOrValue
の型パラメータ(高階関数の返り値の型)を推論します U
を返します
const hof: HOFOrValue<string> = () => () => 'a'; type R = HOFReturnType<typeof hof>; // R は string型
上手くいっているように見えますが、予め型パラメータに与える変数の型をHOF
型に変換しておく必要があります。
const hof2 = () => () => () => () => 2020; const hof3: HOFOrValue<number> = hof2; type R2 = HOFReturnType<typeof hof2>; // R2 は () => () => () => number型 type R3 = HOFReturnType<typeof hof3>; // R3 は number型
クラシルではVuexの型を自前で書いている時によく使いましたが、現在はvuex-smart-moduleへと移行し、使用する機会はほとんどなくなりました。
こういうトリビアを知っておくと、いざというときに型を諦めずに書くことができるので良いです。
8. ネストした配列の中身の型を取り出す方法
高階関数から返り値の型を取り出すのと同じ要領で、ネストした配列からも型を取り出してみたいと思います。
もうお分かりですね、だいぶネタが尽きて来ています。
まずネストした配列もしくはその中身を表す型を定義します。
type NestedArrayOrValue<T> = NestedArrayOrValue<T>[] | T;
次にネストした配列から、中身の型を取り出してみます。
type NestedArrayType< T extends NestedArrayOrValue<any> > = T extends NestedArrayOrValue<infer U> ? U : never;
- 型パラメータとしてネストした配列
T
型を受け取ります(T extends NestedArrayOrValue<any>
) T
をNestedArrayOrValue<any>
型に代入可能かを検査します- その際に
infer U
でNestedArrayOrValue
の型パラメータ(ネストした配列の中身の型)を推論します U
を返します
これも事前に型をNestedArrayOrValue
型に変換しておく必要がありますが、ちゃんと中身の型を取り出せます。
const a1: NestedArrayOrValue<string> = [ 'a', ['b', ['c', 'd', ['e']]], 'f', 'g' ]; type A1 = NestedArrayType<typeof a1>; // A1 は string
複数の型を混ぜることもできます。
const a1: NestedArrayOrValue<string | number | boolean | Promise<string>> = [ 'a', [2020, [true, 'd', [Promise.resolve('e')]]], 'f', 'g' ]; type A1 = NestedArrayType<typeof a1>; // A1 は string | number | boolean | Promise<string>
あらかじめNestedArrayOrValue
型で定義した配列を組み合わせて行くこともできます。
const a1: NestedArrayOrValue<string> = ['a']; const a2: NestedArrayOrValue<string | number> = [a1, 2020]; const a3: NestedArrayOrValue<string | number | boolean | null> = [ a2, true, a1, null ]; const a4 = a3; type A4 = NestedArrayType<typeof a4>; // A4 は string | number | boolean | null
型パラメータを重複して書いている部分を、NestedArrayType
で取り出した型に置き換えてみます。
const a1: NestedArrayOrValue<string> = ['a']; const a2: NestedArrayOrValue<NestedArrayType<typeof a1> | number> = [a1, 2020]; const a3: NestedArrayOrValue<NestedArrayType<typeof a2> | boolean | null> = [ a2, true, a1, null ]; const a4 = a3; type A4 = NestedArrayType<typeof a4>; // A4 は string | number | boolean | null
若干無理やりトリビアをひねり出している感じは否めませんが、次行きます。
9. プロパティを持つ関数オブジェクトの型の書き方
JavaScriptの関数はObjectを継承しているので、関数にもオブジェクトと同じようにプロパティを生やすことが出来ます。
では、このようなプロパティを持つ関数の型を表現するにはどうすれば良いでしょうか?
いくつか方法があります。
複数の型を組み合わせて表現する
関数の型とプロパティを持つオブジェクトの型を別々に定義し、それを組み合わせます。
type SomeFunc = (a: string, b: number) => void; type SomeProps = { c: boolean; d: null; }; type SomeFuncWithProps = SomeFunc & SomeProps; const someFuncWithProps: SomeFuncWithProps = Object.assign( (a: string, b: number) => { console.log(a, b); }, { c: true, d: null } );
interfaceを使って定義することもできます。
interface SomeFuncWithProps extends SomeFunc, SomeProps {}
ちなみにこのObject.assign
で関数にプロパティを生やすトリビアは@uhyo_さんのTweetで知りました。
Object.assignを使うとわりときれいにいく(ぇhttps://t.co/fNdviq5j1d https://t.co/sjNzxvtuNz
— 🈚️うひょ🤪✒📘 (@uhyo_) 2019年11月20日
Callableで表現する
TypeScriptには関数のような呼び出し可能なオブジェクトを、そのものずばり表現するための記法があります。
type Callable = { (): void; };
関数のオブジェクトとしての側面がうかがえる記法ですね。
この記法を使って先程のSomeFuncWithProps
を定義してみます。
type SomeFuncWithProps = { (a: string, b: number): void; c: boolean; d: null; };
無駄な型定義を作らなくて済むし、スッキリと分かりやすくなりました。
10. オーバーロードの型のみを定義する方法
関数のオーバーロードを利用すると、引数の組み合わせを複数定義できます。
これは関数の実装とセットで書く例です。
function someOverloadFunc(a: string): string; function someOverloadFunc(a: number): string; function someOverloadFunc(a: string | number): string { return typeof a === 'string' ? a : a + ''; }
では、この関数の型のみを表現するにはどうすればよいでしょうか? そうです、プロパティを持つ関数オブジェクトの型の書き方で紹介したCallableを使うと、オーバーロード付きの関数の型を定義できます。
type SomeOverloadFunc = { (a: string): string; (b: number): string; }; const someOverloadFunc: SomeOverloadFunc = (a: string | number) => typeof a === 'string' ? a : a + '';
これもVuexに自前で型を書いていた時に、Storeを継承したクラスを用意して、dispatch、commitをラップしたtypedDispatch、typedCommitというメソッドを定義する際に使用していましたが、今はvuex-smart-module(略)
さいごに
TypeScriptは使うほどに新しい発見があって楽しいですね。
そしてまだまだ進化を続けているので、今後がとても楽しみです。
こうやって振り返ってみると、ここで挙げたトリビアのほとんどはVuexに型を付けるために使っていたんだなと気付きました。
記事中でも紹介させていただいたvuex-smart-moduleは、TypeScript + Vue + Vuexを使っている方にとてもおすすめです。
明日はプロダクトデザイナ × プロダクトマネージャーのこばさん(@kazkobay)が「UIデザイン×PdMで広がるデザインの可能性」というタイトルでアップ予定です!
最後に告知です、delyではクラシルのフロントエンドを盛り上げてくれる仲間を絶賛募集中です🙌
ぜひお気軽にご連絡ください。