11のヒントでより良いTypescriptプログラマーになる

11 Tips That Make You a Better Typescript Programmerのカバー画像

ZenStackのためのymc9

2022年12月23日に投稿、2023年1月4日に更新

Typescriptを学ぶのは、よく再発見の旅になります。初期の印象はかなりの誤解を招くこともあります。これはただJavascriptに注釈を加えるだけで、コンパイラが潜在的なバグを見つける手助けをしてくれる、といったところでしょうか?

Typescriptに関する画像

この声明は一般的には真ですが、進めば進むほど、言語の最も信じられない力が組み合わせ、推論、そして型の操作にあるとわかります。

この記事では、その言語を最大限に活用するためのいくつかのヒントをまとめています。

#1 **{セット}**で考える

プログラマーにとって型は日常的な概念ですが、それを簡潔に定義するのは驚くほど難しいです。私には、_セット_を概念的なモデルとして使うことが役立つと感じています。

例えば、新しい学習者はTypescriptの型の組み合わせ方が直感に反すると感じるかもしれません。とてもシンプルな例を見てみましょう:

type Measure = { radius: number };
type Style = { color: string };

// { radius: number; color: string } という型
type Circle = Measure & Style;

&演算子を論理ANDの意味で解釈すると、Circleはダミーの型であると考えるかもしれません、なぜならそれは重なるフィールドがない2つの型の結合だからです。しかし、Typescriptはこのようには機能しません。代わりに、__セット__で考える方が正しい挙動を容易に推論することができます:

  • すべての型は値の_セット_です。
  • いくつかの_セット_は無限です:string、object; いくつかは有限です:boolean、undefined、・・・
  • unknownは_普遍セット_(すべての値を含む)、一方 neverは_空セット_(値を含まない)です。
  • Measureは、radiusという名前の数値フィールドを含むすべてのオブジェクトのセットです。Styleも同じです。
  • &演算子は__交差__を作成します:Measure & Styleは、radiuscolorフィールドの両方を含むオブジェクトのセットを表し、実際にはより小さなセットですが、もっと一般的に利用可能なフィールドを持っています。
  • 同様に、|演算子は__合併__を作成します:2つのオブジェクト型が組み合わされている場合、より大きなセットですが、おそらく共有されているフィールドが少なくなります。

_セット_は割り当てが理解できるようにも助けます:値の型が宛先の型のサブセットであれば、割り当てが許可されます:

type ShapeKind = 'rect' | 'circle';
let foo: string = getSomeString();
let shape: ShapeKind = 'rect';

// stringはShapeKindのサブセットではないので不許可
shape = foo;

// ShapeKindはstringのサブセットなので許可
foo = shape;

以下の記事はセットでの考え方に関して詳細な導入を提供しています。

セットでの考え方に関する画像

TypeScriptと集合論 | Iván Ovejero

Typescriptでの型の割り当てや解決を理解するのに集合論はどう役立つのでしょうか?

favicon ivov.dev

#2 宣言された型と絞り込まれた型を理解する

Typescriptの非常に強力な機能の一つは、コントロールフローに基づいた自動型の絞り込みです。これは、コードの特定の場所において、変数が2つの型を関連付けていることを意味します:宣言型と絞り込まれた型。

function foo(x: string | number) {
  if (typeof x === 'string') {
    // xの型はstringに絞られたので、.lengthが有効です
    console.log(x.length);

    // 割り当ては宣言型を尊重し、絞り込まれた型は尊重しません
    x = 1;
    console.log(x.length); // xはnumberになったので不許可です
  } else {
    ...
  }
}

#3 オプショナルフィールドの代わりに識別されたユニオンを使う

多様な形状のセットを定義するとき、例えばShapeのように、最初は次のように始めるかもしれません:

type Shape = {
  kind: 'circle' | 'rect';
  radius?: number;
  width?: number;
  height?: number;
}

function getArea(shape: Shape) {
  return shape.kind === 'circle' ? 
    Math.PI * shape.radius! ** 2
    : shape.width! * shape.height!;
}

非nullアサーション(radiuswidth、そしてheightフィールドにアクセスする際に使用)が必要です。なぜならkindフィールドと他のフィールドの間に確立された関係がないからです。代わりに、識別ユニオンがはるかに優れている解決策です:

type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;

function getArea(shape: Shape) {
    return shape.kind === 'circle' ? 
        Math.PI * shape.radius ** 2
        : shape.width * shape.height;
}

型の絞り込みにより、強制キャストの必要がなくなりました。

#4 タイプアサーションを避けるために型述語を使う

正しい方法でtypescriptを使う場合、明示的な型アサーション(value as SomeTypeのように)を使用することはまれにしかありません。しかし、時には次のような衝動に駆られることもあるでしょう:

type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;

function isCircle(shape: Shape) {
  return shape.kind === 'circle';
}

function isRect(shape: Shape) {
  return shape.kind === 'rect';
}

const myShapes: Shape[] = getShapes();

// Typescriptはフィルタリングでタイプが絞られることがわからないのでエラー
const circles: Circle[] = myShapes.filter(isCircle);

// アサーションを追加したいと思うかもしれません:
// const circles = myShapes.filter(isCircle) as Circle[];

よりエレガントな解決策は、isCircleisRectを型述語を返すように変更し、filter呼び出しの後、Typescriptが更に型を絞り込むのを助けることです:

function isCircle(shape: Shape): shape is Circle {
    return shape.kind === 'circle';
}

function isRect(shape: Shape): shape is Rect {
    return shape.kind === 'rect';
}

...
// 確実にCircle[]型が正しく推論されます
const circles = myShapes.filter(isCircle);

#5 ユニオン型が配布される方法を制御する

型の推論はTypescriptの本能であり、ほとんどの場合、あなたのために静かに働きます。しかしながら、微妙なあいまいさがある場合には、あなたが介入する必要があるかもしれません。_配布条件型_は、これらのケースの1つです。

もし、入力型が既に配列でない場合に配列型を返すToArrayヘルパータイプがあるとしましょう:

type ToArray<T> = T extends Array<unknown> ? T: T[];

以下のタイプについて、何が推論されるべきだと思いますか?

type Foo = ToArray<string|number>;

答えはstring[] | number[]です。しかし、これはあいまいです。なぜ(string | number)[]ではないのでしょう?

デフォルトでは、Typescriptがジェネリックパラメータ(ここではT)に対してユニオン型(ここではstring | number)に出くわした場合、それを各構成要素に分配します。そして、それがstring[] | number[]を得る理由です。この振る舞いは、特別な構文を使ってTを一対の[]で囲むことによって変更することができます:

type ToArray<T> = [T] extends [Array<unknown>] ? T : T[];
type Foo = ToArray<string | number>;

今、Foo(string | number)[]型と推論されます。

#6 予期しないケースをコンパイル時にキャッチするための網羅的チェックを使用する

enum上でswitch-caseするとき、予期されないケースの場合に積極的にエラーを出す習慣を持つことは良い習慣です。他のプログラミング言語で行われるように無視するのではなく:

function getArea(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rect':
      return shape.width * shape.height;
    default:
      throw new Error('Unknown shape kind');
  }
}

Typescriptでは、never型を利用してエラーをもっと早く見つけるように静的型チェックをさせることができます:

function getArea(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rect':
      return shape.width * shape.height;
    default:
      // 下記で型チェックエラーが発生します
      // もしshape.kindのいずれかが上記で処理されていない場合
      const _exhaustiveCheck: never = shape;
      throw new Error('Unknown shape kind');
  }
}

これにより、新しいshape kindを追加したときにgetArea関数を更新することを忘れることは不可能です。

このテクニックの背後にある理屈は、never型はnever以外のもので割り当てられないということです。もしshape.kindのすべての候補がcase文によって使い尽くされていたら、defaultに到達する唯一の可能な型はneverでしょう。しかし、もしいくつかが網羅されていない場合には、それらはdefaultブランチに漏れ、無効な割り当てを引き起こす可能性があるということです。

#7 interfaceよりもtypeを好む

typescriptでは、typeinterfaceはオブジェクトを型付けする際に非常に似た構造です。おそらく少し物議を醸すかもしれませんが、以下のいずれかが真の場合にのみinterfaceを使い、それ以外のほとんどのケースでは一貫してtypeを使うことをお勧めします:

  • interfaceの"合体(merging)"機能を利用したい場合。

  • クラス/インターフェース階層を含むオブジェクト指向スタイルのコードがある場合。

それ以外には、より多目的なtype構造を常に使用すると、より一貫性のあるコードになります。

#8 適切な場合は配列よりもタプルを好む

構造化されたデータを型付けする一般的な方法はオブジェクト型ですが、時にはコンパクトな表現を望み、シンプルな配列を代わりに使うことがあるでしょう。例えば、私たちのCircleは次のように定義できます:

type Circle = (string | number)[];
const circle: Circle = ['circle', 1.0];  // [kind, radius]

しかし、この型付けは不必要に

こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。
https://dev.to/zenstack/11-tips-that-help-you-become-a-better-typescript-programmer-4ca1