📆

Moment.jsは非推奨だが安易にdate-fnsに変えるな。date-fnsのisValidではparseを活用せよ

2022/03/29に公開
7

問題点

面倒な日付処理をやってくれるMoment.jsですが、mutable設計が混乱を招くとして、今後は非推奨と発表しています。immutableに変更する作業も実施しないとのこと。

https://momentjs.com/guides/#/lib-concepts/mutability/

The moment object in Moment.js is mutable. This means that operations like add, subtract, or set change the original moment object.

var a = moment('2016-01-01'); 
var b = a.add(1, 'week'); 
a.format();
"2016-01-08T00:00:00-06:00" // aも変更されてしまう。a.clone().add(1, 'week');とする必要あり

代替案として date-fns にする人が多いのですが、
結論から言うと 「安易にdate-fnsに変えるな」 です。そちらにも罠があるから。

https://momentjs.com/docs/#/-project-status/

Moment.jsにもdate-fnsにも isValid がありますが、仕様が異なるので注意が必要です。

実在しない日付、例えば2022年2月29日を引数に与えるとどうなるでしょう?

Moment.jsのisValidでは false になりますが、date-fnsのisValidは true になります。

  • Moment.js
moment('2022-13-01','YYYY-MM-DD',true).isValid() //false
moment('2022-01-32','YYYY-MM-DD',true).isValid() //false
moment('2022-02-29','YYYY-MM-DD',true).isValid() //false
moment('2022-04-31','YYYY-MM-DD',true).isValid() //false
  • date-fns

各月31日までなら、実在しない日付でも true を返してしまう。
2月や閏年、30日までの月の扱いには注意が必要。

dateFns.isValid(new Date('2022-13-01')) //false
dateFns.isValid(new Date('2022-01-32')) //false
dateFns.isValid(new Date('2022-02-29')) //true
dateFns.isValid(new Date('2022-04-31')) //true

対策1

32日や13月は false を返すので、Moment.jsの isValid と同じ使い方をしたいのであれば、date-fns の次の特徴を利用します。

  • 2022-02-29(閏年以外の2/29)は、2022-03-01として処理されてしまう。
    • 31日までは無条件にtrue。
    • 実在しない日付は自動で差分が加算される。

だから、次のようにします。
※コメントをいただきまして、修正しました

  • date-fns のバージョンは 1.28.5 です。
const isValidDateString = (str, format) => {
  const formatString = dateFns.format(
    dateFns.parse(str, format),
    format
  )
  return formatString === str;
}

isValidDateString('2022-02-27', 'YYYY-MM-DD') // true
isValidDateString('2022-02-28', 'YYYY-MM-DD') // true
isValidDateString('2022-02-29', 'YYYY-MM-DD') // false
isValidDateString('2022-02-30', 'YYYY-MM-DD') // false
isValidDateString('2022-02-31', 'YYYY-MM-DD') // false
isValidDateString('2022-02-32', 'YYYY-MM-DD') // false
isValidDateString('2022-02-33', 'YYYY-MM-DD') // false
  • CodePenで試した結果
    • date-fns のバージョンは 1.28.5 です。

これでdate-fnsを使用しながら、Moment.jsのisValidと同じ結果を得られます。

対策2

またはこちら。
※コメントをいただきました

  • CodePenで試した結果
    • date-fns のバージョンは 2.28.0 です。

これでdate-fnsを使用しながら、Moment.jsのisValidと同じ結果を得られます。

その他の候補

date-fns以外の候補はこちらです。

全て試してください。今のプロジェクトに合うものを選ぶと良いと思います。

Discussion

standard softwarestandard software

こちら標準のDate の問題みたいです。

console.log((new Date('2022-02-28')).toString()); // "Mon Feb 28 2022 09:00:00 GMT+0900 (日本標準時)"
console.log((new Date('2022-02-29')).toString()); // "Tue Mar 01 2022 09:00:00 GMT+0900 (日本標準時)"
console.log((new Date('2022-02-30')).toString()); // "Wed Mar 02 2022 09:00:00 GMT+0900 (日本標準時)"
console.log((new Date('2022-02-31')).toString()); // "Thu Mar 03 2022 09:00:00 GMT+0900 (日本標準時)"
console.log((new Date('2022-02-32')).toString()); // "Invalid Date"

date-fns だと次のような内容を出力していました。

dateFns.parse('2022-02-27', 'YYYY-MM-DD')  // Sun Feb 27 2022 00:00:00 GMT+0900 (日本標準時)
dateFns.parse('2022-02-28', 'YYYY-MM-DD')  // Mon Feb 28 2022 00:00:00 GMT+0900 (日本標準時)
dateFns.parse('2022-02-29', 'YYYY-MM-DD')  // Tue Mar 01 2022 00:00:00 GMT+0900 (日本標準時)
dateFns.parse('2022-02-30', 'YYYY-MM-DD')  // Wed Mar 02 2022 00:00:00 GMT+0900 (日本標準時)
dateFns.parse('2022-02-31', 'YYYY-MM-DD')  // Thu Mar 03 2022 00:00:00 GMT+0900 (日本標準時)
dateFns.parse('2022-02-32', 'YYYY-MM-DD')  // Fri Mar 04 2022 00:00:00 GMT+0900 (日本標準時)
dateFns.parse('2022-02-33', 'YYYY-MM-DD')  // Sat Mar 05 2022 00:00:00 GMT+0900 (日本標準時)

次のような関数を作って、文字列をパースして得た日付型から、再度フォーマットかけて
一致を確認するといいかも。

const isValidDateString = (str, format) => {
  const formatString = dateFns.format(
    dateFns.parse(str, format),
    format
  )
  return formatString === str;
}

isValidDateString('2022-02-27', 'YYYY-MM-DD') // true
isValidDateString('2022-02-28', 'YYYY-MM-DD') // true
isValidDateString('2022-02-29', 'YYYY-MM-DD') // false
isValidDateString('2022-02-30', 'YYYY-MM-DD') // false
isValidDateString('2022-02-31', 'YYYY-MM-DD') // false
isValidDateString('2022-02-32', 'YYYY-MM-DD') // false
isValidDateString('2022-02-33', 'YYYY-MM-DD') // false

いろいろ気をつけないといけないことが多いですね。

lemonadernlemonadern

コメント失礼します。

こちらで date-fns による意図に反する挙動として挙げられているものは、 JS の Date オブジェクトの挙動に起因するものです。

Date は日付を表す文字列を受け取った場合にそれをパースして Date オブジェクトを作りますが、このときに存在しない日付が渡されてもある程度は柔軟に対応します。
今回で言えば、

  • 2022-02-29(閏年以外の2/29)は、2022-03-01として処理されてしまう。
    - 31日までは無条件にtrue。
    - 実在しない日付は自動で差分が加算される。

という挙動がそれにあたります。

new Date('2022-02-28') // Mon Feb 28 2022 09:00:00 GMT+0900 (Japan Standard Time)
new Date('2022-02-29') // Tue Mar 01 2022 09:00:00 GMT+0900 (Japan Standard Time)
new Date('2022-02-30') // Wed Mar 02 2022 09:00:00 GMT+0900 (Japan Standard Time)
new Date('2022-02-31') // Thu Mar 03 2022 09:00:00 GMT+0900 (Japan Standard Time)
new Date('2022-02-32') // Invalid Date

31日までは実在する日付に置き換えられてしまうので、日付文字列のパースにDateを利用している記事内のコード↓

dateFns.isValid(new Date('2022-13-01')) //false
dateFns.isValid(new Date('2022-01-32')) //false
dateFns.isValid(new Date('2022-02-29')) //true
dateFns.isValid(new Date('2022-04-31')) //true

では、例の問題が発生しています。

この問題は、日付のパースに Date ではなく dateFns.parse()を用いることで解決できます。

dateFns.isValid(dateFns.parse('2022-02-28', 'yyyy-MM-dd', new Date())) // true
dateFns.isValid(dateFns.parse('2022-02-29', 'yyyy-MM-dd', new Date())) // false

実在しない日付である 2022-02-29の isValid で false を得られています。
記事内のコードも同様です↓

- dateFns.isValid(new Date('2022-13-01')) //false
- dateFns.isValid(new Date('2022-01-32')) //false
- dateFns.isValid(new Date('2022-02-29')) //true
- dateFns.isValid(new Date('2022-04-31')) //true
+ dateFns.isValid(dateFns.parse('2022-13-01', 'yyyy-MM-dd', new Date())) //false
+ dateFns.isValid(dateFns.parse('2022-01-32', 'yyyy-MM-dd', new Date())) //false
+ dateFns.isValid(dateFns.parse('2022-02-29', 'yyyy-MM-dd', new Date())) //false
+ dateFns.isValid(dateFns.parse('2022-04-31', 'yyyy-MM-dd', new Date())) //false

記事で紹介されている問題はDateによるものなので、記事タイトルの

date-fnsのisValidは閏年に未対応

という記述は誤りだと思います。
date-fns は何も悪くないのでコメントしてしまいました、偉そうにすみません🙇

2022/03/31 追記

date-fns によって正しく日付文字列のパースができる例を置いておきます。
記事内のCodePenはdate-fnsのバージョンが非常に古いものになっていましたので、こちらで確認することをおすすめします。

standard softwarestandard software

おかしいな...
私の指摘したところと、lemonadernさんの指摘したところで動きが違う。

hooyanさんのCodePenの所を改良して動かしてみました。

<li>
${  dateFns.parse('2022-02-29', 'yyyy-MM-dd') }

${ dateFns.isValid(dateFns.parse('2022-02-28', 'yyyy-MM-dd', new Date())) }
${ dateFns.isValid(dateFns.parse('2022-02-29', 'yyyy-MM-dd', new Date())) }

</li>

結果:Tue Mar 01 2022 00:00:00 GMT+0900 (日本標準時) true true

dateFnsのバージョン違いがあるのか、何かパース時のオプション設定があるのかな。

lemonadernlemonadern

記事で挙げられている CodePen を覗いてみたんですが、CDNとして読み込まれているdate-fnsのバージョンが古いものになっていました。(v1.28.5)
2021/03/31 現在の最新バージョンはv2.28.0なので、かなり古いことがうかがえます。
またdocsによれば、parseは v2 から挙動が大幅に変化しています。
https://date-fns.org/v2.28.0/docs/parse#v2.0.0-breaking-changes

v2.28.0の date-fns を利用したサンプルを作ってみたのでぜひ覗いてみてください〜

standard softwarestandard software

2022-02-29 などの実在しない日付はアウトにした方がいいですよね!
調べていただき、疑問がはれました。
お手間かけてしまいましたが、ありがとうございます。