SPIDERPLUS Tech Blog

建設SaaS「スパイダープラス」のエンジニアとデザイナーのブログ

実践的!TypeScript Tips の紹介!

皆様こんにちは。
S+BIMチームのコモドドラゴンです。

最近、エリッククラプトンのライブに参戦したのですが、まさに、Wonderful tonight でした!

そんな私が今回ブログ記事として、執筆させていただくテーマは、TypeScript Tipsです。

昨今のフロントエンド界隈はSPAやSSRなどの技術が発達したのと同時に、
とても複雑化してきており、生のJSではランタイム時にしかエラーが分からなかったり、
JSDocでパラメータや戻り値の説明を補完していたりするケースも少なくないかと思います。(フロントエンドに限らず、js/tsはバックエンドで使用されることもあります)
そんな時に役に立つのが、JSのスーパーセット(上位互換)であるTypeScriptです。

alt text

引用:https://typescriptbook.jp/top/typescript-as-superset-of-javascript.svg

実際にコードをみていった方が早いと思うので弊社のS+BIMプロダクトでも使っているテクニックを紹介していきます。(※実際に提供中のプロダクトで使用しているコードとは異なります)

満足できなければ、satisfies を使ってみよう

satisfies 演算子、これはTypeScript 4.9から導入された機能です。

ある式が何らかの型と一致することを確認したい一方で、推論の目的でその式の最も具体的な型を保持したいというジレンマのために開発された演算子
https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html

この演算子は、オブジェクトのキーや型の整合性をチェックするのに非常に便利です。

まず、基本的なところとして型注釈(Type Annotation)とsatisfiesの違いから見ていきます。

type Config = {
    theme: 'light' | 'dark'
    size: number
}

// 型注釈:より緩い型になる
const annotationConfig: Config = {
    theme: 'dark',
    size: 16
}
annotationConfig.theme // 'light' | 'dark' (緩い)

// satisfies:厳密な型を保持
const satisfiesConfig = {
    theme: 'dark',
    size: 16
} satisfies Config
satisfiesConfig.theme // 'dark' (厳密)

どちらも、型推論は行われますが、推論のタイミングと型の厳密さに違いがあります。

型注釈の場合

1.右辺の型推論

{
  theme: 'dark',  // 'dark' リテラル型として推論
  size: 16        // 16 リテラル型として推論
}
// → { theme: 'dark', size: 16 } 型

2.適合性チェック

{ theme: 'dark', size: 16 } が Config に代入可能?
  // 'dark' は 'light' | 'dark' に含まれる ✓
  // 16 は number に含まれる ✓
  // → 適合OK

3.最終型決定

// 注釈で指定した型で「上書き」
annotationConfig: Config = { theme: 'light' | 'dark', size: number }

satisfiesの場合

  • 1、2 は同じですが、3の最終型決定が異なります。
    3.最終型決定
// 推論された型をそのまま保持
satisfiesConfig: { theme: 'dark', size: 16 }

このように、型注釈は、型を明示的に上書きするのに対し、

satisfiesは、型推論結果を保ったまま(型注釈のように完全に上書きをせずに)、型付けをします。

特に、「型推論結果を保ったまま」というのがミソで、この性質を利用して、オブジェクトのキーや型の整合性をチェックすることができます。

言葉で書くとわかりにくいので、実際プロダクトで使った例を紹介します。 特に役立った場面は翻訳対応の時です。

  • 例えば、以下のような日本語の翻訳ファイル(ja.ts)があるとします。
export default {
    management: '管理',
    selectAll: '全て選択',
    deselectAll: '全て解除',
    update: '更新',
    site: '現場',
    folderName: 'フォルダ名',
    drawingName: '図面名',
    issuer: '発行者',
    dateOfIssue: '発行日',
    expirationDate: '有効期限',
    remarks: '備考',
};
  • 日本語の翻訳ファイルを英語に翻訳(en.ts)したい際に漏れがないように、satisfiesを使用することができます。
import type ja from './ja';
export default {
    management: 'Management',
    selectAll: 'Select All',
    deselectAll: 'Cancel All',
    update: 'Update',
    site: 'Site',
    folderName: 'Folder Name',
    drawingName: 'Drawing Name',
    issuer: 'Issuer',
    dateOfIssue: 'Date of Issue',
    expirationDate: 'Expiration Date',
    remarks: 'Remarks',
} satisfies typeof ja;

- 試しに、英訳ファイルから、`site` の翻訳を消してみると、エディタがしっかり教えてくれます!

Nice 静的解析ですね!!

ざっくり使い分けの方針としては、以下のようになります。 ※ただし、プロジェクトやチームによって異なる可能性があります。

  • 型注釈を使う場面

    • 変数の型を明示的に指定したい
    • より抽象的な型として扱いたい
    • 関数の引数・戻り値など、型を固定したい
  • satisfiesを使う場面

    • 型の制約はチェックしつつ、具体的な型情報を保持したい
    • オブジェクトのキーの整合性をチェックしたい
    • 型安全性と型推論の恩恵を両方欲しい

as const でリテラル型安全ドライバー

as const(const assertion)は、変数宣言時に使用する演算子で、その値を再起的にreadonlyにした上でリテラル型として扱います。

実際のプロダクトで、CSVデータ列の解析処理で使った例を紹介します。

一週前の記事の、hono/zod-openapiで実現するAPI開発実践 OpenAPIルートの定義でもas const を使った例がありますので、是非アツアツなHonoの世界を覗いてみてください!

techblog.spiderplus.co.jp

  • 例えば、以下のようなコードだと、CSVデータの列を解析する際に、直接的な数値(マジックナンバー)を使用してしまっていて、意図しない値が入ってしまう可能性があります。
for (const row of csvData) {
    const id = row[0]; // ID列
    const user = row[1]; // ユーザ列
    const timestamp = row[2]; // タイムスタンプ列
    // ...
}
  • そこで、as const を使って、列の値をリテラル型として扱うことで、型安全にすることができます。
const DataColumns = {
    ID: 0,
    USER: 1,
    TIMESTAMP: 2,
} as const;
  • このコードでは、DataColumns オブジェクトの値をリテラル型として扱うため、例えば、DataColumns.TIMESTAMP2 というリテラル型になります。

  • また、readonlyなプロパティであるため、誤って代入してしまうことも防げます。

このように、変数の再代入を防ぎつつ、不変な値をリテラル型として扱うことができるのが、as const の利点であり、

コメントを書かずとも、型の表現力によって、コードの意図を明確にすることができます。

味のしなくなったガムは噛みたくない。型を絞り込んで、新しい味を見つけよう

型の絞り込みは、TypeScriptの強力な機能で、特定の型に対してのみ処理を行うことができます。

まずは、タグ付きユニオン(Discriminating Unions)型とNever型(網羅性チェック)を使った例を紹介します。

  • 例えば、以下のようなユーザーデータを扱う場合を考えます。このデータは、タグ付きユニオン型で表現されており、typeプロパティによって内部ユーザーと外部ユーザーを区別しています。
export type User =
    | { type: 'internal'; id: number; group: string }
    | { type: 'external'; id: string; group: string };
  • user.type をswitch文の条件に取ることで型の絞り込み + neverによる網羅性チェックを行うことができます
function getUserInfoSample(user: User): string {
    switch (user.type) {
        case 'internal':
            return `Internal User ID (number): ${user.id}, Group: ${user.group}`;
        case 'external':
            return `External User ID (string): ${user.id}, Group: ${user.group}`;
        default:
            throw new Error(user satisfies never); // 網羅性チェック
    }
}

それぞれ詳しくみていきましょう。

  • タグ付きユニオン型: オブジェクトの型を区別するために、ここでは、type という共通のプロパティ(タグ)を使用します。

  • 型の絞り込み: switch文の条件に user.type を使用することで、TypeScriptは user の型を自動的に絞り込みます。これにより、各ケースで user の型が明確になり、適切なプロパティにアクセスできます。

    • internal の場合は、idnumber

    • external の場合は idstring

  • Never型: 到達不可能な値を表します。user satisfies never は、usernever 型であることを確認するために使用されます。網羅性チェックとして、switch文の default ケースで使用されます。

    • 例えば、途中のcase文を削除してみると、「externalがないよ!」ってエディタが教えてくれます。

JavaScriptでもお馴染みのtypeof/instanceof を使った型の絞り込み

TypeScriptでtypeof/instanceof を使用することで、変数の型の絞り込みが可能になります

  • 以下のような条件分岐をさせる処理があったとします(ブログ用のため条件分岐が多いです 🙇)
class DatabaseError extends Error {
    constructor(message: string) {
        super(message);
        this.name = 'DatabaseError';
    }
}

class NetworkError extends Error {
    constructor(message: string) {
        super(message);
        this.name = 'NetworkError';
    }
}

function processData(data: string | number | boolean | DatabaseError | NetworkError) {
    if (typeof data === 'string') {
        console.log(`string data: ${data}`);
    } else if (typeof data === 'number') {
        console.log(`number data: ${data}`);
    } else if (typeof data === 'boolean') {
        console.log(`boolean data: ${data}`);
    } else if (data instanceof DatabaseError) {
        console.log(`database error: ${data.message}`);
    } else if (data instanceof NetworkError) {
        console.log(`network error: ${data.message}`);
    } else {
        return data satisfies never;
    }
}
  • 注目ポイントは、typeof/instanceof 演算子を使って、data の型を絞り込めているところです。

  • はじめのif文

  • 次のelse if文
  • instanceofも同様に

これまでの例を見ていくと、TypeScriptは開発者がコードを読むときと同じように、制御フローや演算子を理解して、型を絞り込む(Narrowing)ことができます

いわゆる、型ガードと呼ばれるこの手法は、特定の型に対してのみ処理を行う場合や、複数の型が混在する場合に非常に有用です。

まとめ

いかがでしたでしょうか??
S+BIMチームでは、日々このようなTipsをモブプロ・ペアプロで共有しながら開発時に試行錯誤を繰り返しています。 他にもTypeScriptには便利な機能がたくさんありますが、今回は実際にプロダクトで使ったテクニックを例として紹介してみました!

さいごに

スパイダープラスでは仲間を募集中です。
SaaSを支える技術に興味がある方、建設DXのパイオニアになりたい方など大歓迎です。
ぜひ、お気軽にご連絡ください。