TypeScriptで境界に立ち向かう方法を整理してみました。
本編はその導入部になります。機会があれば続編があるかも..

Table of Contents

はじめに

開発をしていると境界と向き合う機会が多いのではないでしょうか。
代表的な境界には以下のようなものがあります。

  • 設定ファイルの入出力
  • Web APIのインタフェース
  • DBにアクセスするDaoとテーブル定義
  • ビジネスロジックとそれ以外

最近だとDDDが再び注目されている気がします。
私もDDD本をちゃんと読んだことが無かったので以下を読み始めました。

本記事ではDDDについて語るつもりはありません。
DDDでも取り上げられている境界について、TypeScriptを使って今までどのように実装してきたかをまとめています。

想定する読者

TypeScriptを使って普通に開発できる読者を想定しています。
TypeScriptで実装したことがない方に向けての補足はありませんが、それでも問題なければご覧下さい。

お題のケース

人というモデル複数から構成されたhumans.jsonを読みこみ、全員のフルネーム+年齢を表示するケースを考えます。

humans.json
[
    {
        "id": 1,
        "name": "ichiro",
        "myoji": "sato",
        "gender": "man",
        "birthday": "1991-01-01"
    },
    {
        "id": 2,
        "name": "jiro",
        "myoji": "tanaka",
        "gender": "man",
        "birthday": "1992-02-02"
    },
    {
        "id": 3,
        "name": "saki",
        "myoji": "suzuki",
        "gender": "woman",
        "birthday": "1993-03-03"
    }
]

フルネームと年齢の定義は以下です。

定義 具体例(ichiroの場合)
フルネーム 名前と名字をハイフンで連結した文字列 ichiro-sato
年齢 2019年3月11日時点の年 28

ベースとなる実装

まずはベースの実装を紹介します。

準備

適当なディレクトリを作成し、npmプロジェクトとしてください。

$ npm init -y

続いて、TypeScriptの開発に必要なpackageをインストールします。

$ npm i typescript ts-node @types/node dayjs

今回は日付を扱うのでdayjsもインストールしました。

最後にTypeScriptプロジェクトとして初期化します。

$ npx tsc --init --target es2015

実装

humans.jsonと同じ階層にmain.jsを作成して実装します。

main.js
import dayjs from 'dayjs';
import fs from 'fs';


enum Gender {
    MAN = "man",
    WOMAN = "woman",
}

interface Human {
    id: number
    gender: Gender
    name: string
    myoji: string
    birthday: string
}

const loadHumans = (): Human[] =>
    JSON.parse(fs.readFileSync(`${__dirname}/humans.json`).toString())

console.log(
    loadHumans()
        .map(x => `${x.name}-${x.myoji}: ${dayjs().diff(dayjs(x.birthday), 'year')}歳`)
        .join('\n')
)

実行すると以下のようになります。

$ npx ts-node main.ts
ichiro-sato: 28歳
jiro-tanaka: 27歳
saki-suzuki: 26歳

ベース実装の問題

JavaScriptのコードに比べれば堅牢でシンプルだと思いますが、このコードにはいくつか問題があります。

  1. InputデータのIFがイケていない
  2. Inputデータが使いにくい
  3. Inputデータが間違っていることがある

それぞれの問題は実装でカバーすることもできます。
ただ、今回紹介する方法の方がシンプルに書けて改修もしやすいと思います。

次のセクションで1つずつ紹介していきます。

InputデータのIFがイケてない

jsonのIFが悪いとまでは言いませんが、良いとは言えません。

interface Human {
    id: number
    gender: Gender
    name: string
    myoji: string
    birthday: string
}

具体的には以下の点が気になります。

  • namemyojiではなく、firstNamelastNameにしたい
  • birthdayはstring型ではなくDate型にしたい

特にbirthdayは登場するたびに ${dayjs().diff(dayjs(x.birthday), 'year')} とするのは非常に冗長でしょう。

改善案

typestack/class-transformerを使うと、変換結果を変数に詰め直さずIFを変更できます。

npmでインストールします。

$ npm i class-transformer reflect-metadata

reflect-metadataはデコレータを使うために必要です。 tsconfig.jsonも変更する必要があります。

  • experimentalDecoratorstrueにする
  • strictPropertyInitializationfalseにする
experimentalDecorators を true にする理由
デコレータは試験的な機能であるため、使用する事を明示的に示さなければいけないためです。
strictPropertyInitialization を false にする理由

class-transformerを利用する場合、constructorを使いません。

一方、contructorが無いClassでプロパティの型をOptionalにするとエラーになります。
それを抑制するための設定変更です。

プロパティ 'id' に初期化子がなく、コンストラクターで明確に割り当てられていません。
strictPropertyInitializationfalseにしているか確認しましょう。

実装

plainToClass(クラス, json文字列)でjson文字列をクラスにparseできます。
第2引数はObjectでもOKです。

main.ts
import { Expose, plainToClass, Transform } from "class-transformer";
import dayjs, { Dayjs } from 'dayjs';
import fs from 'fs';
import "reflect-metadata";

enum Gender {
    MAN = "man",
    WOMAN = "woman",
}

class Human {
    id: number
    gender: Gender

    @Expose({ name: "name" })
    firstName: string

    @Expose({ name: "myoji" })
    lastName: string

    @Transform(value => dayjs(value), { toClassOnly: true })
    birthday: Dayjs
}

const loadHumans = (): Human[] =>
    plainToClass(Human, JSON.parse(fs.readFileSync(`${__dirname}/humans.json`).toString()))

console.log(
    loadHumans()
        .map(x => `${x.firstName}-${x.lastName}: ${dayjs().diff(x.birthday, 'year')}歳`)
        .join('\n')
)

humans.jsonから読み込まれるプロパティ名を@Exposeアノテーションのnameプロパティに指定すると、Humanクラスのプロパティと一致しなくても変換されます。

@Transformで変換関数を指定すると、読み込まれるプロパティを変換することもできます。
この例では、dayjsのコンストラクタを通してDayjs型に変換しています。

これらの対応により、loadHumans()後の変換ロジックは以下のように変化しました。

-       .map(x => `${x.name}-${x.myoji}: ${dayjs().diff(dayjs(x.birthday), 'year')}歳`)
+       .map(x => `${x.firstName}-${x.lastName}: ${dayjs().diff(x.birthday, 'year')}歳`)
 

1行の文字数だけを見るとそこまで変わらないかもしれません..
ただ、本当にひどいIFに遭遇したときは便利ですよ。

プロパティ内部で完結する関係なら、Exposeアノテーションが使えます

Inputデータが使いにくい

フルネームを出すのに${x.firstName}-${x.lastName}
年齢の計算にdayjs().diff(x.birthday, 'year')

モデルの外側でこのような計算をできればしたくないと思います。
とはいえ..モデルを別のモデルに詰め替えたり、utilityを作ってそれを呼び出すのも大袈裟と感じたことはないでしょうか。

改善案

getterを適切に設けることで、ロジックをモデルに隠蔽できます。
フルネームは.fullName、年齢は.ageでアクセスできるようにしてみましょう。

実装

getterを追加した実装です。

main.ts
import { Expose, plainToClass, Transform } from "class-transformer";
import dayjs, { Dayjs } from 'dayjs';
import fs from 'fs';
import "reflect-metadata";

enum Gender {
    MAN = "man",
    WOMAN = "woman",
}

class Human {
    id: number
    gender: Gender

    @Expose({ name: "name" })
    firstName: string

    @Expose({ name: "myoji" })
    lastName: string

    @Transform(value => dayjs(value), { toClassOnly: true })
    birthday: Dayjs

    get fullName(): string {
        return `${this.firstName}-${this.lastName}`
    }

    get age(): number {
        return dayjs().diff(this.birthday, 'year')
    }
}

const loadHumans = (): Human[] =>
    plainToClass(Human, JSON.parse(fs.readFileSync(`${__dirname}/humans.json`).toString()))


console.log(
    loadHumans()
        .map(x => `${x.fullName}: ${x.age}歳`)
        .join('\n')
)

この対応でloadHumans()後の変換ロジックは以下のように変化しました。

-       .map(x => `${x.firstName}-${x.lastName}: ${dayjs().diff(x.birthday, 'year')}歳`)
+       .map(x => `${x.fullName}: ${x.age}歳`)
 

とてもシンプルで、ビジネスロジック(要件)が簡潔に記載されていますね。素晴らしい😄

モデル内部で完結する関係なら、getterが使えます

Inputデータが間違っていることがある

最後は Input間違っている問題 です。

冒頭で境界の例としてあげた以下について、期待したものが必ず来るとは言えませんよね。

  • 設定ファイルの入出力
  • Web APIのインタフェース

受け取った後にValidation関数へ突っ込む方法はあります…が辛いですよね..
本筋ではないValidationロジックのせいで見通しが悪くなることも多々あります。

今回の例では以下のようなケースを想定します。

  • genderにotoko (man, woman以外の値が入る)
  • idが文字列 (数値型ではない)
  • myojiが空 (必須項目に値が入っていない)
humans.json
[
    {
        "id": 1,
        "name": "ichiro",
        "myoji": "sato",
        "gender": "man",
        "birthday": "1991-01-01"
    },
    {
        "id": 2,
        "name": "jiro",
        "gender": "otoko",
        "birthday": "1992-02-02"
    },
    {
        "id": "saki",
        "name": "saki",
        "myoji": "suzuki",
        "gender": "lady",
        "birthday": "1993-03-03"
    }
]

Validation関数を実装していないので、このまま実行すると 何事もなく動きます

$ npx ts-node main.ts
ichiro-sato: 28歳
jiro-undefined: 27歳
saki-suzuki: 26歳

なんかundefinedという名字の方がいますね..🙏

改善案

typestack/class-validatorkを使うと、アノテーションを付けるだけでプロパティがルールに従っているかを判定してくれます。

npmでインストールします。

$ npm i class-validator class-transformer-validator

先に紹介したtypestack/class-transformerと一緒に使うため、typestack/class-transformer-validatorもあわせてインストールします。

実装

各プロパティにアノテーションを追加しています。
Validation失敗時に例外が送出されるため、例外処理も追加しました。

main.ts
import { Expose, Transform } from "class-transformer";
import { transformAndValidate } from "class-transformer-validator";
import { IsDefined, IsEnum, IsNumber } from 'class-validator';
import dayjs, { Dayjs } from 'dayjs';
import fs from 'fs';
import "reflect-metadata";


enum Gender {
    MAN = "man",
    WOMAN = "woman",
}

class Human {
    @IsNumber()
    id: number

    @IsEnum(Gender)
    gender: Gender

    @Expose({ name: "name" })
    firstName: string

    @Expose({ name: "myoji" })
    @IsDefined()
    lastName: string

    @Transform(value => dayjs(value), { toClassOnly: true })
    birthday: Dayjs

    get fullName(): string {
        return `${this.firstName}-${this.lastName}`
    }

    get age(): number {
        return dayjs().diff(this.birthday, 'year')
    }
}

const loadHumans = async (): Promise<Human[]> =>
    await transformAndValidate(Human, fs.readFileSync(`${__dirname}/humans.json`).toString())
        .then(humans => humans as Human[])


async function main() {
    try {
        const ret = (await loadHumans())
            .map(x => `${x.fullName}: ${x.age}歳`)
            .join('\n')
        console.log(ret)
    } catch (e) {
        console.error(JSON.stringify(e, null, 4))
    }
}

main()

plainToClassではなくtransformAndValidateを使っていることに注意してください。
Validationに失敗した場合は以下のObjectがエラーとして渡されます。

実行結果
[
    [],
    [
        {
            "target": {
                "id": 2,
                "firstName": "jiro",
                "gender": "otoko",
                "birthday": "1992-02-01T15:00:00.000Z"
            },
            "value": "otoko",
            "property": "gender",
            "children": [],
            "constraints": {
                "isEnum": "gender must be a valid enum value"
            }
        },
        {
            "target": {
                "id": 2,
                "firstName": "jiro",
                "gender": "otoko",
                "birthday": "1992-02-01T15:00:00.000Z"
            },
            "property": "lastName",
            "children": [],
            "constraints": {
                "isDefined": "lastName should not be null or undefined"
            }
        }
    ],
    [
        {
            "target": {
                "id": "saki",
                "firstName": "saki",
                "lastName": "suzuki",
                "gender": "lady",
                "birthday": "1993-03-02T15:00:00.000Z"
            },
            "value": "saki",
            "property": "id",
            "children": [],
            "constraints": {
                "isNumber": "id must be a number"
            }
        },
        {
            "target": {
                "id": "saki",
                "firstName": "saki",
                "lastName": "suzuki",
                "gender": "lady",
                "birthday": "1993-03-02T15:00:00.000Z"
            },
            "value": "lady",
            "property": "gender",
            "children": [],
            "constraints": {
                "isEnum": "gender must be a valid enum value"
            }
        }
    ]
]

使用できるアノテーションは https://github.com/typestack/class-validator にまとめられています。
今回使用したアノテーションは以下3つです。

アノテーション 意味
@IsEnum 指定されたEnum値になりうるか
@IsNumber 数値であるか (数の文字列はNG)
@IsDefined 値が存在するか (null, undefined以外)

コード量はまた増えてしまいましたが、if文を1つも書くことなく堅牢なValidationが可能になりました。

プロパティで完結する関係なら、Validationのアノテーションが使えます

総括

TypeScriptで境界と向き合い、堅牢で可読性が高いコードを書く方法を紹介しました。

今回紹介したpackageはどちらもVersion1.0未満です。
それでも動作は安定しており改修に強いコードが書けるので、リスク以上にメリットが大きいのではないでしょうか。