dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

思わずへ〜ってなったTypeScriptのトリビア10選

はじめに

こんにちは、フロントエンドエンジニアのall-userです!

これはdelyアドベントカレンダー9日目の記事です。
昨日はプロダクトデザイナーのkassyさんプレゼンツ「デザインとエンジニアリングをつなぐために重要な3つのこと」でした。

dely.design

開発現場でも直面することの多いコミュニケーションの問題と、それに対して心掛けていることについて書かれていて、うんうんとうなずきながら読んでしまいました。ぜひこちらもご覧ください!

それでは、TypeScriptを使ったクラシルのフロントエンド開発の中で、思わずへ〜となったトリビアたちを紹介したいと思います。

目次

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

f:id:alluser:20191209153837p:plain

どうしてエラーになってしまうのか?

  1. index.tsaをimportします
  2. agetBというメソッドの中でbを参照するため、bをimportします
  3. bはコンストラクタの引数にaを受け取るため、aをimportします
    • この時のa.tsモジュールは初期化が完了していないため空のオブジェクトになります
    • a.tsが空のオブジェクトのためnamed exportされたaundefinedになります
  4. aundefinedのためnew B(a)new B(undefined)となります
  5. a.getB().aundefinedになります
  6. 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でビルドして実行してみると今度はエラーが起きません。

f:id:alluser:20191209154052p:plain

どうしてエラーにならないのか?

このトリビアは以下の記事で紹介されていました。

medium.com

internal.tsを経由することで、b.tsaをimportするタイミングでinternal.tsaが事前に初期化されているようにうまいこと調整されています。

  1. index.tsainternal.tsからimportします
  2. internal.tsa.tsをexportします
  3. agetBというメソッドの中でbを参照するため、binternal.tsからimportします
    • この時のinternal.tsは初期化が完了していないため空のオブジェクトになります
    • internal.tsが空のオブジェクトのためnamed exportされたbundefinedになります
    • bgetBが呼ばれるまで遅延評価されるため、この時点でのbundefinedは評価されません
  4. internal.tsb.tsをexportします
  5. bはコンストラクタの引数にaを受け取るため、aをimportします
    • この時のinternal.tsモジュールには、すでにexportされたaが存在します
  6. aを参照することができるため、new B(a)は成功します
  7. a.getB().aaと同一になります
  8. a.getB().a.getB()bを返します

全てのエラーを回避できるわけではない

上記の仕組みを考えてみると、internal.tsが空のオブジェクトであることが許容できないケースではエラーを回避できないことが分かります。

index.tsinternal.tsを以下のように書き換え、new B(a)されるタイミングでinternal.tsaが初期化されていない状況を意図的に作ると、やはりエラーが発生します。

// 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になるのではなく、その値を評価した時点でエラーになります。

f:id:alluser:20191209154222p:plain

f:id:alluser:20191209154240p:plain

vuex-smart-moduleのModuleをinternal.tsでまとめる

クラシルではTypeScript + Vue + Vuexを使っていて、当初Vuexの型は自前で書いていましたが、現在はvuex-smart-moduleというライブラリを導入し、快適に型の恩恵を受けられるようになりました。

github.com

通常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、本当にすばらしいですね。

www.typescriptlang.org

ということで、すでに使い所がなくなったトリビアですが、めげずに紹介していきたいと思います。

自身を参照する型をtypeで定義するとエラーになる(TS3.6まで)

type Json =
    | string
    | number
    | boolean
    | null
    | { [property: string]: Json }
    | Json[];

f:id:alluser:20191209154332p:plain

interfaceだとエラーにならない

interfaceでは型の解決が遅延されるらしくエラーになりません。

type Json =
    | string
    | number
    | boolean
    | null
    | JsonObject
    | JsonArray;

interface JsonObject {
    [property: string]: Json;
}

interface JsonArray extends Array<Json> {}

f:id:alluser:20191209154355p:plain

どうしてエラーにならないのか?

このトリビアは以下のstack overflowの回答で知りました。

stackoverflow.com

また、エラーにならない理由についてのissueコメントのリンクが貼られています。

github.com

interfaceではプロパティの型と継承元の型の解決が遅延されるので、このような回避策が実現できるようです。

3. document.querySelectorは引数の型から返り値のElement型を判定してくれる

document.querySelectorの返り値の型は、引数の型によってどの要素なのかを判定してくれます。
どんな型でも判定できるわけではなく、セレクタが単一の要素名(タグ名)の時のみ有効です。

f:id:alluser:20191209154506p:plain

どうやって判定しているのか?

querySelectorの型定義を覗いてみると、オーバーロードされた定義がありました。

f:id:alluser:20191209154542p:plain

HTMLElementTagNameMapというinterfaceに全ての要素名と対応する要素の型がマッピングされていて、キーに一致するString Literal型を引数に取ると、返り値の型が確定するという仕組みになっているようです。

f:id:alluser:20191209154559p:plain

もちろん、document.querySelectorAllにも対応してくれています。
素敵です。

4. 存在しないプロパティの存在チェックをする方法

存在しないプロパティに対し、ドットアクセスや添字でアクセスしようとするとコンパイルエラーになってしまいます。

type SomeObject =
  | {
      a: string;
    }
  | {
      b: number;
    }
  | {
      c: boolean;
    };

declare const someObj: SomeObject;

if (someObj.c) { // エラーになる
  console.log(someObj);
}

f:id:alluser:20191209154625p:plain

in演算子を使用するとプロパティの有無をチェックすることができます。

if ('c' in someObj) {
  console.log(someObj); // someObj は { c: boolean }型
}

ちゃんと型の絞り込みもできます。

f:id:alluser:20191209154640p:plain

素敵です。

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を使うと幸せになれます。

f:id:alluser:20191209154724p:plain

String Enumsに適したケース

そんな便利なString Literal Union Typesですが、String Enumsが適しているケースもあります。

先程の例ではsomeVarLiteralSomething型なので、オートコンプリートが効き、型の安全性も担保されていましたが、比較対象がstring型の場合、オートコンプリートが効かず、型の安全性も担保されません。

declare const mightA: string;

if (mightA === EnumSomething.a) { // Enumsの場合オートコンプリートが効く & EnumSomething型であることが担保される
  // doSomething();
}

if (mightA === 'a') { // String Literalの場合オートコンプリートが効かない & LiteralSomething型であることが担保されない
  // doSomething();
}

タイプミスでaaと打ってしまった場合、Enumsではエラーがでますが、String Literalでは比較対象がstring型のため、コンパイルが通ります。

f:id:alluser:20191209154752p:plain

f:id:alluser:20191209154805p:plain

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を代入しようとしてエラーになります。

f:id:alluser:20191209154939p:plain

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;
  1. 型パラメータとして高階関数T型を受け取ります(T extends HOFOrValue<any>
  2. THOFOrValue<any>型に代入可能かを検査します
  3. その際にinfer UHOFOrValueの型パラメータ(高階関数の返り値の型)を推論します
  4. 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型

f:id:alluser:20191209154956p:plain

f:id:alluser:20191209155011p:plain

クラシルではVuexの型を自前で書いている時によく使いましたが、現在はvuex-smart-moduleへと移行し、使用する機会はほとんどなくなりました。
こういうトリビアを知っておくと、いざというときに型を諦めずに書くことができるので良いです。

8. ネストした配列の中身の型を取り出す方法

高階関数から返り値の型を取り出すのと同じ要領で、ネストした配列からも型を取り出してみたいと思います。
もうお分かりですね、だいぶネタが尽きて来ています。

まずネストした配列もしくはその中身を表す型を定義します。

type NestedArrayOrValue<T> = NestedArrayOrValue<T>[] | T;

次にネストした配列から、中身の型を取り出してみます。

type NestedArrayType<
  T extends NestedArrayOrValue<any>
> = T extends NestedArrayOrValue<infer U> ? U : never;
  1. 型パラメータとしてネストした配列T型を受け取ります(T extends NestedArrayOrValue<any>
  2. TNestedArrayOrValue<any>型に代入可能かを検査します
  3. その際にinfer UNestedArrayOrValueの型パラメータ(ネストした配列の中身の型)を推論します
  4. Uを返します

これも事前に型をNestedArrayOrValue型に変換しておく必要がありますが、ちゃんと中身の型を取り出せます。

const a1: NestedArrayOrValue<string> = [
  'a',
  ['b', ['c', 'd', ['e']]],
  'f',
  'g'
];

type A1 = NestedArrayType<typeof a1>; // A1 は string

f:id:alluser:20191209155058p:plain

複数の型を混ぜることもできます。

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>

f:id:alluser:20191209155112p:plain

あらかじめ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

f:id:alluser:20191209155244p:plain

型パラメータを重複して書いている部分を、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

f:id:alluser:20191209155335p:plain

若干無理やりトリビアをひねり出している感じは否めませんが、次行きます。

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で知りました。

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.design

最後に告知です、delyではクラシルのフロントエンドを盛り上げてくれる仲間を絶賛募集中です🙌
ぜひお気軽にご連絡ください。