🎻

TypeScriptの3つの黄金ルール(独自解釈)

2024/04/28に公開

最近はTypeScriptを仕事上にて常に使用しています。基本的にReact&Next.jsとの組み合わせが主な利用方法です。

しかし、話しをする前に、日常的にTypeScriptをどのように使用しているかについての紹介をする必要があります。

自分にとって、TypeScriptで書かれたコードベースが健全であるとはどういうことか? TypeScriptが負担を増やすのではなく、役に立つようにするために設定すべきルールは何か?を自分なりの思考や解釈を交えた記事になります。
もしかすると、この記事を読む場合、TypeScriptを少し使ったことがある必要があるかもしれません。
しかし、コードでコンパイラによくブロックされたり、使用するたびに敗北感を感じた経験がある方は、きっと有益な情報が見つかる可能性が高いと思います。🤞

まず、このTypeScriptを使用する理由に立ち返りましょう。一度使ってみると手放せなくなる二つの要素があります。

    1. 安全ネット: 変数や関数を正しい方法で使用していない場合、それを指摘してくれる。
    1. 開発体験と特にそのオートコンプリートの質: 自分のコードや使用しているライブラリの発見が容易になる。

しかし、以下の場合、TypeScriptはすぐに痛みを伴うものになり得ます。

  • コンパイラに特定の型を無視させると、安全ネットに大きな穴が開いてしまうこと。
  • 型をあちこちで再記述することで、各変更や更新が煩雑になること。

これを防ぐために、日常的にコーディングする際に私が考える3つの黄金ルールを紹介します。

もちろん、これらのルールには明らかに例外も存在しますが、これらを念頭に置くことで、コードの品質を向上させ、特に開発体験(DX)を改善することができるはずです。

必要な場合にのみ型を定義する

// ❌ Bad
const values: string[] = ['椅子', 'テーブル']

// ✅ Goood
const values = ['椅子', 'テーブル']

TypeScriptには型推論システムが備わっています。これは、与えられた値から型を最大限に定義しようとするということです。
例えば、上記のコードでは、割り当てられた値から直接それが文字列の配列であることを見て取ることができるため、配列が文字列の配列であると明示する必要はありません。
この機能を利用することで、コードの読み書きが大幅に楽になります。例えば、関数の結果を変数に割り当てる場合などです。

const value = getValue()

getValue の戻り値の型が変数 value の型を表しています。ですから、自分で型を書く必要はありません。
しかし、TypeScriptが代わりに処理を行えない場面もあります。それは関数の定義に関する部分です。

function sum(a, b) {
  return a + b
}

ここでは、abnumber型であることをTypeScriptが自動で知ることはできません。したがって、それを明示する必要があります。

function sum(a: number, b: number) {
  return a + b
}

これは関数のすべてのパラメータに当てはまります。
一方で、戻り値の型は省略可能です。実際、次のように書く必要はありませんでした。

function sum(a: number, b: number): number {
  return a + b
}

しかし、これは型推論が代わりに行える場合でも、私が型を追加する傾向にある例外です。実際、この型を書くことで、コーディングする前に関数の動作について考えることを強制されます。

これは、型が安定していない場合や限界ケースに特に役立ちます。例えば、hello('Hanako')と呼び出したときにHello Hanako!という文字列を返すべきhello関数の例を考えてみます。

function hello(name: string) {
  return `Hello ${name}!`
}

もし最終的にnamenullablenull許容)である場合はどうすればよいでしょうか?戻り値の型を事前に予測していない場合、最初の反応は次のように書くことでしょう。

function hello(name: string | null) {
  // 空の文字列のケースも同時に処理することができる
  if (!name) {
    return null
  }

  return `Hello ${name}!`
}

しかし、その結果として型の複雑さが増します。なぜなら、戻り値がnullまたはstringのどちらかになるからです。これは、hello関数を使用するすべての場所で、このnullを引きずることを意味します。そして、このnullはおそらく広がり続け、あらゆる場所で!== nullの条件を強いることになることでしょう。

一方で、戻り値の型をfunction hello(name: string | null): stringと強制することで、TypeScriptnullを返すことが関数のシグネチャを変更し、複雑にするという事実を発見するのに役立ったでしょう。

したがって、これらのnullを避けるために、ここではデフォルトの文字列を返すことを好みます。

function hello(name: string | null): string {
  if (!name) {
    return 'Hello world!'
  }

  return `Hello ${name}!`
}

as const

TypeScriptが完全にあなたのニーズを予測しない別の状況は、静的な文字列の場合です。

これはしばしば定数の場合に当てはまります。

const STATUS_LOADING = 'loading'
// 型は 'loading' であり、string ではない
const STATUS_COMPLETE = 'complete'
// 型は 'complete' であり、string ではない
const STATUS_ERROR = 'error'
// 型は 'error' であり、string ではない

ここでの違いは、TypeScriptがこれが任意の文字列ではなく、書いた特定の文字列であることを理解できるという点です。これは、変数を const キーワードで宣言したためです。

しかし、異なる方法で書いていた場合、TypeScriptはそれらを文字列として解釈していたでしょう。

const statuses = {
  loading: 'loading',
  complete: 'complete',
  error: 'error',
}
// type {
// 	loading: string,
// 	complete: string,
// 	error: string,
// }

今、statusesで定義された文字列のうちの一つだけを値として持つ変数を作りたいとしましょう。その場合、次のようなコードを書くことになります。

type Values<T extends Record<string, unknown>> = T[keyof T]
let status: Values<typeof statuses>

💡 このコードが奇妙に見える場合でも、パニックになる必要はありません。型の派生に関する第三の> ルールで、これについて詳しく説明します。

しかし、これはlet status: stringと書いたのと同じことです。ですから、status = "toto"としても、TypeScriptは何の問題も見つけられません。しかし、私たちはstatusesで利用可能な文字列の中から一つだけを欲していました。

この状況で非常に便利な解決策は、値の最後にas constを追加することです。

const statuses = {
  loading: 'loading',
  complete: 'complete',
  error: 'error',
+} as const

この小さな変更により、扱う型がずっと正確になります。

{
  readonly loading: 'loading',
  readonly complete: 'complete',
  readonly error: 'error',
}

したがって、変数statusの型はもはやstringではなく、'loading' | 'complete' | 'error'になりますが、コードはほとんど変更していません。

これは、コード内の定数や設定の管理に特に役立ちます。手動で型を書くよりも、この方法を積極的に使用することをお勧めします。

💡 注意点として、場合によってはenumを使用するだけで十分なこともあります。しかし、それが常に実用的であるわけではなく、コードを変換する必要があります。
一方でas constは、どんなJavaScriptコードとも完全に互換性があります。

参考資料: constアサーション「as const」 (const assertion)

では、第二のルールへ。

anyを決して使用しない

anyはTypeScriptのオープンバー(制限がなく何でもできる状況)です。変数の型をanyと宣言すれば、何でもできます。数値との加算を試みますか?問題ありません。そのキーを取得しようとしますか?もちろんです。そのキーの中のキーを取得しますか?はい。実際の値がundefinedであっても 🤷

この理由から、できるだけ避けるのが賢明です。さもなければ、通常のJSを行っているのと同じです。安全ネットもなければ、オートコンプリートもありません。

しかし、時には本当にどの型を使用すべきかわからないことがあります。
ReactのPropsの例を考えてみましょう。プロパティの値は本当に何でもあり得ます。数値、ブール値、文字列、オブジェクト、関数などです。

したがって、最初の反応はこのようにプロパティを記述することです。

type Props = Record<string, any>

// または
type Props = { [key in string]: any }

しかし、それを行うと、プロパティをどのようにでも操作できてしまいます。

props.quantity + props.className // ok

一方で、anyunknownに置き換えると、TypeScriptは操作しようとしているものの型が分からないと警告してくれます。

props.quantity + props.className
// props.quantity is of type unknown
// props.className is of type unknown

これはすでに良い第一歩です。今、受け取ったデータの形式が明らかでないと考える必要があります。

よく見るオプションの一つは、asキーワードを使用することです。

const quantity = props.quantity as number
// quantity: number

しかし、それを行うことで、TypeScriptに「信じて」と言っているだけです 😵‍💫🐍。これはオープンバーですが、意識的なオープンバーです。
本当に間違いの可能性がない場合には、作業を速めることができるかもしれません。しかし、それは依然としてオープンバーであるため、通常それをお勧めしません。

より良い方法は、データを使用する前にJavaScriptを使ってデータの型を確認または強制することです。

// データを変換する
const quantity = Number(props.quantity);
// quantity: number
if (Number.isNaN(quantity)) {
    // 型は正しいですが、
    // 実際の数値でない場合も
    // 処理する必要があります
}

// 型を確認する
const quantity = props.quantity;
if (typeof quantity === 'number') {
    // ...
}

時には面倒な作業になることもありますが、そのような状況でZodValibotのようなライブラリが役立つことがあります。

しかし、少なくともそれによって、データの品質が保証されます。

💡 この件に関しては、マット・ポーコックさんの記事「An unknown can't always fix an any」が良い読み物になるでしょう。

とはいえ、ほとんどの場合、anyunknownを使う必要は本来ないはずです。特に、親コードがすでにquantityの型の検証を行っている可能性が高いです。その場合、型の派生を使用する方がより良い解決策になるでしょう。

⚠️ 例外は常に存在します。例えば、データが2行前に定義されており、コードレベルでリスクがないが、型を追加するのが複雑な場合は、最もシンプルな方法を選ぶべきです。次にコードを見る人のためにコメントを追加し、自動テストを行い、次のステップに進みましょう。

重要なのは、教条的(柔軟性がなく、固定された信念や規則に厳格に従う態度や行動)にならずに、それがもたらすリスクを理解しておくことです。

型の派生を優先する

スカラー型(string、number、booleanなど)を扱う場合、TypeScriptの型推論が非常にうまく機能するため、一般的には型を繰り返し指定する必要はほとんどありません。

しかし、オブジェクトを操作し始めると、TypeScriptは戻り値の型を推測するのが難しくなります。
例として、children キーを除く全てのオブジェクトを取得したい関数を考えてみましょう。

これは、例えばReactpropsを処理する際に便利かもしれません。

function getAttributes(props) {
    const attributes = { ...props }
    delete attributes['children']
    return attributes
}

const props = {
    className: 'button',
    children: 'Send',
}
// type: { className: string, children: string }

const attributes = getAttributes(props)
// { className: 'button'}

TypeScriptでgetAttributes関数をできるだけ直接的に記述する場合、次のように書くことになります。

function getAttributes(props: Record<string, unknown>): Record<string, unknown>

確かに、それは機能します。入力としてキー/値のオブジェクトを受け取り、出力として新しいキー/値のオブジェクトを返します。

しかし問題は、className属性を再利用しようとすると、TypeScriptが不満を示すことです。

attributes.className // type `unknown`

TypeScriptにclassNameの型をどのように知らせるかですが、attributes.className as stringとすることは許されません。これはTypeScriptに強制を加え、エラーのカテゴリーを見逃すことになるからです。

むしろ解決策は、関数の型定義を改善することにあります。現在、関数のシグネチャで戻り値の型を新しいレコードとして記述していますが、実際にはキーを削除した同じRecordであるべきです。

function getAttributes(props: Record<string, unknown>): Record<string, unknown>
//                            ^ この Record は、前段の Record とは異なります。 ^

これをTypeScriptに説明するためには、「ジェネリック」として扱う必要があります。考え方としては、型を変数に格納するようなものです。

function getAttributes<Props extends Record<string, unknown>>(
  props: Props
): Props
💡 もし getAttributes<T extends ...> の形式に慣れていて、この Props が奇妙に感じられる場合は、説明を読むためにここをクリックしてください。

ジェネリックを使用する型付きコードを見るとき、型変数が一文字で名付けられていることがよくあります。TKVなどです。
これは通常、その型変数に与えたい名前の頭文字です。TypeKeyValue。しかし、実際の単語を使用することもできます(ここではPではなくPropsのように)。

これには利点と欠点があります。

  • 一文字を使用することで、ジェネリックな型を操作していることを明確に区別できます。
  • 完全な単語を使用すると、すぐに読みやすくなりますが、時には既に定義されている他の外部型と重複することがあります。

したがって、あなたの状況に最も意味のある方法を選んでください。チーム内で機能するものが最も良いルールです。😊

上記のコードでは、キーワードextendsを使用して、Propsという型変数にRecord<string, unknown>の制約を持つ型を格納しています。したがって、Propsは必ずキーと値のオブジェクトになります。

このように、TypeScriptがコードをコンパイルする際には

  1. getAttributes関数をpropsパラメーターと共に呼び出す際、その型が{ className: string, children: string }であることを認識します。
  2. これにより、自動的にProps = { className: string, children: string }と推論されます(これを型推論と呼びます)。
  3. したがって、関数の戻り値の型がPropsであるため、同じオブジェクト形式{ className: string, children: string }を正確に再利用します。

これはすでに素晴らしい第一歩ですが、現時点ではattributesが常にchildrenキーを持つオブジェクトになってしまいます。したがって、元のオブジェクトからchildrenキーを除外するために戻り値の型を変換する必要があります。

Omit<Props, 'children'>

function getAttributes<T extends Record<string, unknown>>(
  props: T
): Omit<T, 'children'>

したがって、もし{ className: string, children: ReactNode }を入力として渡していたら、関数getAttributesの戻り値の型は{ className: string }になるでしょう。

そして、その結果、関数の結果を操作するときには、確かに正しい型が得られます。

const attributes = getAttributes(props)
attributes.className // type string

💡 TypeScriptにはOmitのような、すでに用意されている便利な型(ユーティリティタイプ)がたくさんあり、それらが役立つことがあります。必要な時にすぐに思い出せるように、ぜひチェックしてみてください!

💡 また、どんなコードにも通じることですが、目的を達成する方法はしばしば複数存在します。そのため、問題を別の角度から見ることが役立つことがあります。最初に思いつくアイデアが常に最良とは限りません。

例えば、このように型を変更することもできたかもしれません。

function getAttributes<
  Attributes extends Record<string, unknown>
  Props extends Attributes & {children: ReactNode}
>(props: Props): Attributes

Omitを使ってchildrenプロパティを除外するのではなく、Propsが多くのキー(Attributes)とchildrenキーから構成されていることを最初から明確にしました。

さらに、型変数を追加してジェネリックにすることができるとお伝えしましたが、実際には好きなだけ多くの型変数を追加し、好きな名前を付けることができます。そのため、ここではAttributesPropsを定義しました。

汎用型(ジェネリクス)

最後に、型の派生について話す際、私は直接関数から始めました。
しかし、この型の汎用性の概念は単一の型にも適用可能です。ほぼ同じように機能します。

type Attributes<T extends Record<string, unknown>> = Omit<T, 'children'>

これは、同じ型付けを複数の場所で再利用できるため便利です。そのため、私はこのように関数を書き直すことができたでしょう。

function getAttributes<T extends Record<string, unknown>>(
  props: T
): Attributes<T>

コードが一般的であればあるほど、それに頼る必要があります。可能性は本当に無限大です。型の定義に非常に深く入り込むことができます。

それでも、まだまだ迷うこともあります。これはTypeScriptの最も複雑な部分であり、理解するのに時間がかかります。しかし、徐々に特定の概念に慣れ、他の概念を発見することができるようになります。

まとめ

やっとのことで終わりに到達しました。少し汗をかきましたが、最後までやりきりました。

見てきたように、TypeScriptは基本的な部分がしっかりと型定義されていれば、実際には目立たなくなることができます。これにより多くのバグを防ぐことができるだけでなく、開発体験も大幅に向上します。

この理由から、以下の3つのルールに従って健全な基盤に投資する価値があります。

  • 必要な型のみを定義する
  • anyasを使用しない(as constは除く)
  • 型を再定義するのではなく、派生させる

皆さんは、他にどのような点を見てきましたか。

Discussion