📘

TypeScriptで「DateRange型」の正しさを保証する

2023/06/05に公開

こんにちはmofmofでエンジニアをしているshwldです。

今回はTypeScriptで「DateRange型」のような型を作る利点とその正しさを保証する方法を書きます。

DateRange型の例

type DateRange = Readonly<{ since: Date; until: Date; }>;

利点について

Domain Modeling Made FunctionalはF#で関数型によるDDDを実践する本ですが、型の表現力によってドキュメンテーションすることを書いています。
TypeSciprtも型の表現力が豊かです。F#と同じように型によるドキュメンテーションがある程度可能です。

例えば、「開始日~終了日の間」のような値は普通、開始日が終了日より若い日付でないといけません。

type FilterByDateRange = (data: Data[], since: Date, until: Date) => Data[];

この関数の型定義、どうでしょうか。
良さそうに見えますよね。

でも

function filterByDateRange(data: Data[], since: Date, until: Date): Data[] {
    if (since >= until) {
        throw new Error("普通、開始日が終了日より若い日付でないといけません")
    }
    return data.filter(it => it.date >= since && it.date <== until)
}

正しくフィルタできない場合(上記の場合は例外)がありそうです。
型によるドキュメンテーションを進めると、エラーの可能性を型に表現することができます。

type FilterByDateRange = (data: Data[], since: Date, until: Date) => Data[] | InvalidDateRangeError;

filterする際にエラーになる事がわかるようになりました。
(こういうのは例外にせず無視するケースも多そうですが、それはそれでフィルタされない原因がわかりづらそうです)

しかしこのバリデーションはこの処理の中でやるべきなのでしょうか。
事前にやっておきたいですよね。

if (since < until) {
    const newData = filterByDateRange(data, since, until);
}

関数を呼ぶ前に正しさを保証しておきたいと思うのが自然だと思います。

こんな時は sinceuntil をまとめたValueObjectを作って正しさを保証するとよいです。

type FilterByDateRange = (data: Data[], dateRange: DateRange) => Data[]

DateRange これです。
正しく作られたDateRange型を受け取ることを型で明示することで、関数から例外を取り除く事ができました。

正しさを保証する方法

正しいdateRangeを渡すと嬉しいことがわかったところで、どうやってDateRangeの正しさを保証すればよいのでしょうか。まずは普通に書いてみます。

date-range.ts
type DateRange = { since: Date; until: Date };
function DateRange(since: Date, until: Date): DateRange | null {
    if (since < until) return { since: Date; until: Date };
    
    return null
}

良さそうに見えますが、ダメです。

TypeScriptは「構造的部分型」を採用する言語です。
https://typescriptbook.jp/reference/values-types-variables/structural-subtyping#構造的部分型とは

形が同じであれば同じ型とみなすことができてしまいます。
なので、date-range.tsDateRange をエクスポートしてないとしても誰でも自由に作ることができるのです。

例えば以下は型エラーにならないため、since, untilが正しいことは保証できません。

filterByDateRange(data, { since: new Date(), until: new Date() })

形が同じであっても別の型とみなしてもらいたいので、そういう場合は公称型を作ります。
作る方法はいくつかあるようですが、仮に公称形が作れたとして、どのような挙動になるのかみてみます。

import { DateRange } from './date-range';

type FilterByDateRange = (data: Data[], dateRange: DateRange) => Data[]

const filterByDateRange: FilterByDateRange = (data, range) => {
  // 省略
}

const data: Data[] = //
const since: Date = //
const until: Date = //

// これは型エラーにしたい
filterByDateRange(data, { since, until });

// これは型チェック通したい
const dateRange = DateRange({ since, until });
filterByDateRange(data, dateRange);

それでは、これを実現する型をdate-range.tsに書いてみます。

date-range.ts
// exportしないこと
const symbol = Symbol('isDateRange');
// exportしないこと
type IsDateRange = {
  [symbol]: true;
};

export type DateRange = Readonly<
  {
    since: Date;
    until: Date;
  } & IsDateRange
>;
export const DateRange = ({
  since,
  until,
}: {
  since: Date;
  until: Date;
}): DateRange | null => {
  if (since >= until) {
    throw new Error('Invalid date range');
  }
  return {
    since,
    until,
    [symbol]: true,
  };
};

こんな感じになりました。
ポイントは

type DateRangeconst DateRange 以外をexportしないことです。

exportしてしまうとこんな感じで無理やり通されてしまいます。

import { symbol } from './common-types/date-range';

const dateRange3: Readonly<{ since: Date, until: Date, [symbol]: true }> = { since: new Date(), until: new Date(), [symbol]: true };
filterByDateRange([], dateRange3);

最後に

公称型を使うことで、ValueObjectの値を保証することができました。
まあまあ手数はあるので、依存が気にならなければzodを使うと少し短くかけます。

date-range.ts
import { z } from 'zod';

// exportしないこと
const symbol = Symbol('isDateRange');
export const DateRange = z
  .object({ since: z.date(), until: z.date() })
  .refine((dateRange) => dateRange.since < dateRange.until, {
    message: 'Invalid date range',
  })
  .brand(symbol);
export type DateRange = z.infer<typeof DateRange>;
mofmof inc.

Discussion