TypeScript で 'MM/DD' な文字列から Date オブジェクトを作るまで
繁忙期から休む間もなく確定申告まで終えたゆきだるまのみなさん、おつかれさまでした。
今回はよりいっそうフレッシュさのない備忘録を残していきたいと思います。
この記事について
Web アプリケーションの UI 開発で、ユーザーに入力いただいた 08/08
のような日付を表す文字列を Date オブジェクトに変換して諸々処理することがしばしばありました。
この Date オブジェクトに変換する処理を作るときに考えたことが色々あったので、この記事にとりとめなく書いておきたいと思います。
要求
概ね以下のような要求がありました。
-
'08/08'
など、ゼロパディングで'MM/DD'
の形式になっている文字列を日付と解釈して、 Date オブジェクトを返す関数がほしい。- UI の横幅や文脈の都合により、文字列には 月と日しかない。 そのため、パラメータには文字列の他に、対象となる期間の開始日と終了日を Date オブジェクトで与える。
-
'abcd'
'08/32'
など、日付として正しくない文字列だった場合は Date オブジェクトを生成せず、正しくなかったことがわかる値を返す。- バリデーションを細分化するときりがないため、どのように正しくなかったかの理由は戻り値からわからなくてよい。
文字列を Day.js などで直接パースすれば手早く Date オブジェクトを作れますが、文字列自体に年がないので追加する必要があったり、 'MM/DD'
以外の形式を除外するようにしたいというのもあり、今回はあえて使わずに実装したいと思います。
実装
先に結論として、要求に対して TypeScript (執筆時点で v4.6.3)で次のように実装してみました。
実際の業務では他にもう少し細かい要求がありましたが、少し簡単にしています。
convertMmddStringIntoDateArray(mmddString, term)
という関数で Date オブジェクトの配列を返すことができます。
第2引数 term
には1年を超える期間も指定でき、第1引数 mmddString
に該当するすべての日付の Date オブジェクトを早い順に並べた配列を返します。
console.log(
convertMmddStringIntoDateArray(
'03/30',
{start: new Date(2022, 3, 1), end: new Date(2024, 2, 31)}
)
) // -> [ 2023-03-29T15:00:00.000Z, 2024-03-29T15:00:00.000Z ]
'abcd'
のように形式に則っていない文字列や、 '08/32'
のように存在しない日付の文字列に対しては、空の配列を返します。
console.log(
convertMmddStringIntoDateArray(
'abcd',
{start: new Date(2022, 3, 1), end: new Date(2023, 2, 31)}
)
) // -> []
console.log(
convertMmddStringIntoDateArray(
'08/32',
{start: new Date(2022, 3, 1), end: new Date(2023, 2, 31)}
)
) // -> []
閏年にも対応しています。
console.log(
convertMmddStringIntoDateArray(
'02/29',
{start: new Date(2023, 3, 1), end: new Date(2024, 2, 31)}
)
) // -> [ 2024-02-28T15:00:00.000Z ]
console.log(
convertMmddStringIntoDateArray(
'02/29',
{start: new Date(2022, 3, 1), end: new Date(2023, 2, 31)}
)
) // -> []
以降で処理の各部分について説明します。
文字列を検証し、月と日に分ける
以下のように、文字列が 'MM/DD'
形式であることを正規表現で検証し、該当した場合は月と日の各値を取得します。
/**
* 文字列が 'MM/DD' 形式であった場合に MM と DD をそれぞれ number に変換する。
* 文字列が 'MM/DD' 形式でなかった場合は undefined を返す。
* @param mmddString - 'MM/DD' 形式であることが期待される文字列。
*/
const convertMmddStringIntoNumberTupleIfPossible = (
mmddString: string
): [number, number] | undefined =>
/^([0-9]{2})\/([0-9]{2})$/.exec(mmddString)?.slice(1, 3)
.map((str) => parseInt(str, 10))
'abcd'
などの文字列はこの時点で弾かれます。
その場合の戻り値は undefined としています。 null などにする人も多そうですが好みだと思います。
なお、私の実際の業務では、この関数を通す前に、 '8/8'
などをゼロパディングに直したり、全角の数字を半角に直すなどの処理を入れていました。
特に私が担当するサービスは日付や金額でも全角のまま入力するユーザーが多いらしく、全角で動かない機能を作ると実際にすぐ問い合わせをいただいてしまうようでした。
この時点の戻り値は 13 以上の月や 32 以上の日など明らかにおかしいものも含んでいますが、後の別の検証処理に続きます。
Date オブジェクトの配列を生成する
前述の通り月と日の値が得られたら、それが期間中に該当する日付を Date オブジェクトにします。
/**
* 条件に該当する日付の Date オブジェクトの配列を早い順に生成する。
* @param [month, day] - 対象となる日付の月と日。
* @param term - 日付の範囲となる期間。
*/
const convertMonthAndDayIntoDateArray = (
[month, day]: [number, number],
term: {start: Date; end: Date}
): Date[] => {
const startDate = recreateDate(term.start)
const startTime = startDate.getTime()
const endDate = recreateDate(term.end)
const endTime = endDate.getTime()
if (endTime - startTime >= 0) {
const startYear = startDate.getFullYear()
const termYears = endDate.getFullYear() - startYear
return [...Array(termYears + 1)]
.map((_, passedYear) => startYear + passedYear)
.filter((year) => {
const date = createDateIfPossible(year, month, day)
if (date) {
const time = date.getTime()
if (time >= startTime && time <= endTime) {
return true
}
}
return false
})
.map((matchedDate) => new Date(matchedDate, month - 1, day))
}
return []
}
期間は、「ある西暦の 1 年間」などでよければもっと楽なのですが、私の仕事では 3 月決算までの 1 年間といった西暦をまたぐ期間を求められているため、開始日と終了日で指定しています。
開始日と終了日から西暦の候補を挙げ、各候補と月と日から Date オブジェクトを作り、 Date.prototype.getTime
で比較して期間内に収まっていれば該当するとみなし、戻り値の配列に含めます。
この処理自体は単純ですが、以下のようにいくつか細かい処理を入れました。
期間の Date オブジェクトは生成し直す
今回の関数が実際に使われる際、開始日と終了日の Date オブジェクトがどのように生成されたのかわからないので、時、分などの時刻が入っている可能性もあります。
そうすると Date.prototype.getTime
の比較がうまくいかない可能性があるため、一定の引数で Date オブジェクトを生成し直します。
/**
* 年月日のみの引数で Date オブジェクトを再度生成する。
* 時刻などが設定されていて .getTime() に差が出ることを防ぐために用いる。
* @param date - 対象の Date オブジェクト。
*/
const recreateDate = (date: Date): Date =>
new Date(date.getFullYear(), date.getMonth(), date.getDate())
存在しない日付は除外する
年月日の 3 引数が入った Date コンストラクタ は、有限な number の値であれば 13 月でも 0 日でも構わず加算して日付のデータにします。つまり 13 月は翌年の 1 月になり、 0 日は前月の最終日になります。
今回の UI はそのような仕様にしたくないので、存在しない年月日の組み合わせは除外することにします。
以下のように、一度生成した Date オブジェクトから再度月日を取得して、パラメータと一致しているかをチェックするようにしました。
一致していればその Date オブジェクトを返し、一致していなければ undefned を返します。
/**
* 年月日の数が存在する組み合わせだった場合にその Date オブジェクトを返し、
* 存在しなかった場合は undefined を返す。
* @param year - 西暦の年。
* @param month - 月。 new Date() 等と異なり、 0 ではなく 1 を 1 月とする。
* @param day - 日。
*/
const createDateIfPossible = (
year: number,
month: number,
day: number
): Date | undefined => {
const date = new Date(year, month - 1, day)
return (
date.getMonth() === month - 1 &&
date.getDate() === day
? date
: undefined
)
}
これにより 13 月や 0 日といった常にありえない月日だけでなく、閏年でない 2 月 29 日も 3 月 1 日になることで検出され、除外することができます。
以上から、正しい文字列のみを検出して Date オブジェクトを生成できました。
余談
今回は Date オブジェクトを扱っているために煩雑な処理が多くなってしまいましたが、執筆時点で Stage 3 の Temporal API が使えるようになると、時刻のない日付を扱えたり、存在しない日付を throw できたりするようなので、多少楽になるかもしれません。
// まだ仕様が未確定なのと、実務で使ったことがないので、イメージです。
/**
* 指定した年月日に該当する日付を生成する。存在しなかった場合は undefined 。
* @param date - 年月日。
*/
const getPlainDate = (
date: {year: number; month: number; day: number}
): Temporal.PlainDate | undefined => {
try {
return Temporal.PlainDate.from(date, {overflow: 'reject'})
} catch (_e) {
return undefined
}
}
const plainDate1 = getPlainDate({year: 2022, month: 8, day: 18})
console.log(
plainDate1 ? plainDate1.toString() : 'rejected'
) // -> '2022-08-18'
const plainDate2 = getPlainDate({year: 2022, month: 2, day: 29})
console.log(
plainDate2 ? plainDate2.toString() : 'rejected'
) // -> 'rejected'
変更履歴
- 2022-3-31: 文字列を検証し、月と日に分ける のサンプルコードと CodePen を修正しました。
-
RegExp.prototype.test
とArray.prototype.split
を使っていましたが、ご指摘をいただき、より汎用性のあるRegExp.prototype.exec
を使う方法に修正しました。
-
- 2022-3-31: 存在しない日付は除外する のサンプルコードと CodePen 、説明を修正しました。
- 存在しない日付を判定するために年月日すべてを検証していましたが、ご指摘をいただき、必要十分に月日だけを検証するようにしました。
Discussion
以前似たようなことをやったことがあり、他の方に教えていただいたりもしましたので、もしかしたらご参考になるかもなのでリンクします。
手前味噌で、宣伝になっている感じもしますが、すいません。m(.. )m
コピペで済ませる。JavaScript 環境ならどこでも使えそうなそんなに長くないコードで、フォーマット指定して文字列からDate型に変換する関数 - Qiita
ありがとうございます。大変勉強になります。あらためて奥の深い処理だと思いました!