Date や 日時文字列の変換 Intl.DateTimeFormatを使った存在しないフォーマットの作り方

最近は `Intl.DateTimeFormat` がとても便利なので 紹介します。

# `Intl.DateTimeFormat` とは

`Intl.DateTimeFormat` は `Date` を任意の文字列フォーマットに変換する為のクラスです。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat

通常は `format(date)` で フォーマットします。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/format

ただ、必要なフォーマットが存在しない！今回はそういうときの話です。

# `formatToParts(date)` の使い方

`formatToParts(date)` という 部品単位で どういう部品であるかの情報をつけて出力してくれるものがあります。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts

つまり、必要な部品を用意してくれるのです。

```ts
const from = new Date();
const options: Intl.DateTimeFormatOptions = {
  year: "numeric",
  month: "2-digit",
  day: "2-digit",
  hour: "2-digit",
  minute: "2-digit",
  second: "2-digit",
  calendar: "iso8601",
  fractionalSecondDigits: 3,
  timeZoneName: "shortOffset",
};
const format = new Intl.DateTimeFormat("ja-JP", options);
const { year, month, day, hour, minute, second, fractionalSecond, timeZoneName } = ((parts) => {
  return Object.fromEntries(parts.filter(({ type }) => type !== "literal").map(({ type, value }) => [type, value])) as {
    year: string;
    month: string;
    day: string;
    hour: string;
    minute: string;
    second: string;
    fractionalSecond: string;
    timeZoneName: string;
  };
})(format.formatToParts(from));
const formatted = `${year}-${month}-${day}T${hour}:${minute}:${second}.${fractionalSecond}`;
console.log(formatted);
// -> "2025-01-19T14:04:22.377
```

https://www.typescriptlang.org/ja/play/?target=11#code/MYewdgzgLgBAZgJxAWxgXhmApgdxgEQEMosAKASgG4AoUSWEABygEtwIAuGASTCgBsAdERIAVFsiwAxEAmTEA8szaR0MAN7UYMAJ5ZCCLgCIwAV0kIWwIwBotMZOCgALYwCYAtABMWAcxZQtvZehDru3n4BQdrOIKaGMEaePv6BdtrILGYk4SlR6TAQWHReuZFp9sCE-FhgIQlGLBAgABwAbAAMAIzR8AiEwKzg1QDKxeBe+OWcMADMBaySAFrgWAByhJLGELEIUApwcEUVAL40dNDwsvKwGNh4vALCxFjikjJyxKRGAFaEHgApAAKthgTCGkCotHYsHUun0CBsDicziRIR0SNi8SRmWyWCRRRKSMQAwho3GdSRiywK2wG0kMBOalIpEYBigEHI6AAfBp7AgsFB4mAYAoAEY-YpQQSIFAAUT4liwEFZ7IgMpY-BICBZcKgOkYWEZXLQvP1hpgAEI0BgjPwAlh+vwjORBPJGLqYOb8TAAG7VUxGk4m3kAbW9SP9-EDAF1yFzCBA+dptHoDFxoJYwL4aCnkXxXIUoFmc-ZtOiM8WsqW81iEpnq7mU7jTDkiyWm9pCRNKx2y31SSpySVe43+9TaetNlhR9mm2dqMHSHBrsQZauoKIQEC1cukMh4+cYVdPlASF41AADAAk6jTCBOHlvjgLj9v6JOolvdZOHGfWVbLBf1vbs6hOQRbxJQYh34MYShOS8j0gEAakEfgQF8ZcN3PKggA

これを使いまわすなら次の様な関数を作るとよいでしょう。

```ts
const toLocal = makeToLocal();
{
  const date = new Date();
  console.log(`${date.toISOString()} -> ${toLocal(date)}`);
  // -> "2025-01-19T05:06:41.549Z -> 2025-01-19T14:06:41.549"
}
{
  const date = new Date().toISOString();
  console.log(`${date} -> ${toLocal(date)}`);
  // -> "2025-01-19T05:06:41.551Z -> 2025-01-19T14:06:41.551"
}
/**
 * timeZone を local に 変換する
 */
function makeToLocal() {
  let toLocalFormat: Intl.DateTimeFormat | undefined;
  return toLocal;
  /**
   * timeZone が local な 日付文字列を返す
   */
  function toLocal(from: string): string;
  /**
   * timeZone が local な Date を返す
   */
  function toLocal(from: Date): string;
  function toLocal(from: string | Date): string {
    const isString = typeof from === "string";
    if (isString) {
      from = new Date(from as string);
    }
    if (typeof from === "string") throw new Error();
    const { year, month, day, hour, minute, second, fractionalSecond } = ((parts) => {
      return Object.fromEntries(parts.filter(({ type }) => type !== "literal").map(({ type, value }) => [type, value])) as {
        year: string;
        month: string;
        day: string;
        hour: string;
        minute: string;
        second: string;
        fractionalSecond: string;
        timeZoneName: string;
      };
    })((toLocalFormat ??= initial()).formatToParts(from));
    const result = `${year}-${month}-${day}T${hour}:${minute}:${second}.${fractionalSecond}`;
    return result;
    function initial() {
      const options: Intl.DateTimeFormatOptions = {
        year: "numeric",
        month: "2-digit",
        day: "2-digit",
        hour: "2-digit",
        minute: "2-digit",
        second: "2-digit",
        calendar: "iso8601",
        fractionalSecondDigits: 3,
        timeZoneName: "shortOffset",
      };
      return new Intl.DateTimeFormat("ja-JP", options);
    }
  }
}
```

https://www.typescriptlang.org/ja/play/?target=11#code/MYewdgzgLgBFIBkTAIYBsYF4YFsUGsBTAFUWXQAoBKAbgCgBvOmGUSWAExSkKxjEIB3GABFuhavRZsIINIQB0aEAHMKAAwAkDLjwXwAkgGUA8kagAnAJZg1VAL4wAtAD4Y2+ElRoKuwg-VaOntGZlZwaBg-PgFhMR5qfRBjM0sbOylwyDlFZTUtHXFHV3cGT3IfPwCgkIB6ACp65nq4KxxCAC1wXkAkhhhlbxhAawYYQEhNQG3jQE0GQGiGZtq6ADMAVzBgKCtwXAISMm9qGCYWeVhy7wAxEAs8KAAuGAMwKDQFeJI2wgur7hgAHxgVjiEBY2QgcTIWQhQJYWMBwXboTINJosGAtdbtLoCGCAGQZ+hUYIArBhggFPTQAbcoBw00A6tqAdCUeoAV+MmYVR8xYy1W602p0oCwsIBwd2g1lsVAFaVsiMaTLR70xvFxA3QhNE4hg9MZKPqLJgbLWG1hXJ8PL5d1eIpggvSmR1HP18MNvP55rFKl+yp4ZottgOTKykSsEHMQpd2CgAE8AA6EEALbUOrCYbAAIk9KkTmRYVhjFH9gfSVG9KJRRpwMSEbokxZgKAgTqDQULIULmZgFDDkejsb58aTKcT+agAAtecJYjAAKIWXkWSQ+mSwBgwUOEFAWAA0uHAg-XXFD64HIGh65wNiWPHXEEIbA4655KF14HQRkv4A4MEc2AoFHDK6gEHzmDcQ5CxgCEoRhGATAAIwAK0vKAFGLMdHmsQgIC-H8IAQqw0B4acKAXNteHsf83EImAAEIExgRM0CsXD0D7BQ8HDT8CIjQh1wAN3QJYiJImAAG1CK4njCAAXSofNqwLYCWCXFdRSDdNgJwTcB0Uy0fULHcNPFLSUX3aFdJUZTC2PMBT0IYzTJRC8r2s-TWQsO8bUfZ8wA4BzZJYdFOm6AA5FB2i84D7GU4jPwNT5rhgAB+WLsBsOirEoSSEMua5SAABQwihi0k5S5xA1ClhwvgCnkix7CcbRVMeAdqu0Hd7GIbRDKqm5apPHh7E6hg7JfewFG0W97zANyr3sdRlNA6FYQhCBSqgZTrT1GAkvWVKZMLIqQHDG0IDuB4nhecRiHeaLuBMfa9RrbAgNkyq7kTCz2msYBE1XRyN3q56ACYnA4KwVDoz7vp06iAaBkGoDB7yYHa-7AeB0Gvvh8zLKR6HUe+gaPKxlHYbR7zvEIDyFOo-0QAADgANgABgARjh7zRtctAnyvERCcOmAAGZidk3zZUC4LqIgfcLCgEwFgWC8if0sL9Nm8DR2O55XnO9pLqgChE2glAnAAKSyz6YD2g76xRRs32COggA

以上。