diff --git a/package-lock.json b/package-lock.json
index 120d317..c3b4cbc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,7 +21,7 @@
         "style-loader": "^3.3.2",
         "ts-loader": "^9.5.1",
         "ts-node": "^10.9.2",
-        "typescript": "^5.3.3",
+        "typescript": "^5.4.2",
         "webpack": "^5.76.2",
         "webpack-cli": "^5.0.1",
         "webpack-dev-server": "^5.0.2",
@@ -2527,9 +2527,9 @@
       "dev": true
     },
     "node_modules/@types/node": {
-      "version": "20.11.24",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz",
-      "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==",
+      "version": "20.11.25",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.25.tgz",
+      "integrity": "sha512-TBHyJxk2b7HceLVGFcpAUjsa5zIdsPWlR6XHfyGzd0SFu+/NFgQgMAl96MSDZgQDvJAvV6BKsFOrt6zIL09JDw==",
       "dev": true,
       "dependencies": {
         "undici-types": "~5.26.4"
@@ -3343,9 +3343,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001594",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001594.tgz",
-      "integrity": "sha512-VblSX6nYqyJVs8DKFMldE2IVCJjZ225LW00ydtUWwh5hk9IfkTOffO6r8gJNsH0qqqeAF8KrbMYA2VEwTlGW5g==",
+      "version": "1.0.30001596",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz",
+      "integrity": "sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ==",
       "dev": true,
       "funding": [
         {
@@ -3985,9 +3985,9 @@
       }
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.4.693",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.693.tgz",
-      "integrity": "sha512-/if4Ueg0GUQlhCrW2ZlXwDAm40ipuKo+OgeHInlL8sbjt+hzISxZK949fZeJaVsheamrzANXvw1zQTvbxTvSHw==",
+      "version": "1.4.698",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.698.tgz",
+      "integrity": "sha512-f9iZD1t3CLy1AS6vzM5EKGa6p9pRcOeEFXRFbaG2Ta+Oe7MkfRQ3fsvPYidzHe1h4i0JvIvpcY55C+B6BZNGtQ==",
       "dev": true
     },
     "node_modules/emoji-regex": {
@@ -6668,12 +6668,12 @@
       }
     },
     "node_modules/qs": {
-      "version": "6.11.2",
-      "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
-      "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==",
+      "version": "6.12.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.0.tgz",
+      "integrity": "sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==",
       "dev": true,
       "dependencies": {
-        "side-channel": "^1.0.4"
+        "side-channel": "^1.0.6"
       },
       "engines": {
         "node": ">=0.6"
@@ -7800,9 +7800,9 @@
       }
     },
     "node_modules/terser": {
-      "version": "5.29.0",
-      "resolved": "https://registry.npmjs.org/terser/-/terser-5.29.0.tgz",
-      "integrity": "sha512-RXY80V6CBOVdZhyVwqsUHxOGcdFYSU1pCHTEF9UcQ2OWsacZiSyykd7CfAKfZFI6yfbRntv9EaoMW2TQNpyXjg==",
+      "version": "5.29.1",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-5.29.1.tgz",
+      "integrity": "sha512-lZQ/fyaIGxsbGxApKmoPTODIzELy3++mXhS5hOqaAWZjQtpq/hFHAc+rm29NND1rYRxRWKcjuARNwULNXa5RtQ==",
       "dev": true,
       "dependencies": {
         "@jridgewell/source-map": "^0.3.3",
@@ -8128,9 +8128,9 @@
       }
     },
     "node_modules/typescript": {
-      "version": "5.3.3",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
-      "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
+      "version": "5.4.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz",
+      "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==",
       "dev": true,
       "bin": {
         "tsc": "bin/tsc",
diff --git a/package.json b/package.json
index 9175043..6380e66 100644
--- a/package.json
+++ b/package.json
@@ -31,7 +31,7 @@
     "style-loader": "^3.3.2",
     "ts-loader": "^9.5.1",
     "ts-node": "^10.9.2",
-    "typescript": "^5.3.3",
+    "typescript": "^5.4.2",
     "webpack": "^5.76.2",
     "webpack-cli": "^5.0.1",
     "webpack-dev-server": "^5.0.2",
diff --git a/src/base.ts b/src/base.ts
index 6446716..c9cbf59 100644
--- a/src/base.ts
+++ b/src/base.ts
@@ -256,19 +256,6 @@ export function renderLink(id: string, type: ItemType, names: I18nObject) {
   )}">${formatName(names)}</a>`;
 }
 
-export function groupBy<W, T>(f: (w: W) => T, ws?: W[]): Map<T, W[]> {
-  const map = new Map<T, W[]>();
-  return (
-    ws?.reduce((m, w) => {
-      const key = f(w);
-      const arr = m.get(key) ?? [];
-      arr.push(w);
-      m.set(key, arr);
-      return m;
-    }, map) ?? map
-  );
-}
-
 function renderQTableRow(
   materials: Material[],
   objects: (OfMaterial | [Domain, number])[],
diff --git a/src/components/characters_table.ts b/src/components/characters_table.ts
index 674700f..1453acc 100644
--- a/src/components/characters_table.ts
+++ b/src/components/characters_table.ts
@@ -1,4 +1,4 @@
-import { TYPE_CHARACTER, groupBy, renderLink } from "../base";
+import { TYPE_CHARACTER, renderLink } from "../base";
 import { hasBookmarks } from "../bookmarks";
 import { DELIMITER, I18nObject, formatName } from "../i18n";
 import { Character, characters } from "../models/characters";
@@ -8,7 +8,7 @@ const title: I18nObject = { en: "Characters", "zh-CN": "角色" };
 export class CharactersTable extends HTMLElement {
   constructor() {
     super();
-    const byRarity = groupBy((o) => o.rarity, characters);
+    const byRarity = Map.groupBy(characters, ({ rarity }) => rarity);
     const rarities = Array.from(byRarity.keys()).sort().reverse();
     this.innerHTML = `<details class="section" ${hasBookmarks() ? "" : "open"}>
         <summary>🦸 ${formatName(title)}</summary>
diff --git a/src/components/enemies_table.ts b/src/components/enemies_table.ts
index 7ff827f..3e4109a 100644
--- a/src/components/enemies_table.ts
+++ b/src/components/enemies_table.ts
@@ -7,7 +7,6 @@ import {
   TYPE_WEEKLY_BOSS,
   getTimezone,
   getWeekday,
-  groupBy,
   renderDomainLink,
   renderLink,
 } from "../base";
@@ -37,14 +36,14 @@ const regions: Record<Region, I18nObject> = {
 export class EnemiesTable extends HTMLElement {
   constructor() {
     super();
-    const weeklyBosses: Map<Region, Enemies.Boss[]> = groupBy(
-      (b) => b.region,
+    const weeklyBosses: Map<Region, Enemies.Boss[]> = Map.groupBy(
       Enemies.bosses.filter((b) => b.type === TYPE_WEEKLY_BOSS),
+      (b) => b.region,
     );
     const weeklyBossKeys = Array.from(weeklyBosses.keys());
-    const bosses: Map<Region, Enemies.Boss[]> = groupBy(
-      (b) => b.region,
+    const bosses: Map<Region, Enemies.Boss[]> = Map.groupBy(
       Enemies.bosses.filter((b) => b.type === TYPE_BOSS),
+      ({ region }) => region,
     );
     const bossKeys = Array.from(bosses.keys());
     const talentDomains = Enemies.domains.filter(
diff --git a/src/components/weapons_table.ts b/src/components/weapons_table.ts
index 9626aca..bc0c4cd 100644
--- a/src/components/weapons_table.ts
+++ b/src/components/weapons_table.ts
@@ -1,4 +1,4 @@
-import { TYPE_WEAPON, groupBy, renderLink } from "../base";
+import { TYPE_WEAPON, renderLink } from "../base";
 import { hasBookmarks } from "../bookmarks";
 import { DELIMITER, I18nObject, formatName } from "../i18n";
 import { Category, Weapon, weapons } from "../models/weapons";
@@ -7,15 +7,15 @@ const title: I18nObject = { en: "Weapons", "zh-CN": "武器" };
 export class WeaponsTable extends HTMLElement {
   constructor() {
     super();
-    const byRarity = groupBy((w) => w.rarity, weapons);
+    const byRarity = Map.groupBy(weapons, ({ rarity }) => rarity);
     const rarities = Array.from(byRarity.keys()).sort().reverse();
     this.innerHTML = `<details class="section" ${hasBookmarks() ? "" : "open"}>
       <summary>🗡️ ${formatName(title)}</summary><table class="ctable">
       ${rarities
         .map((rarity) => {
-          const ws2: Map<Category, Weapon[]> = groupBy(
-            (w) => w.category,
-            byRarity.get(rarity),
+          const ws2: Map<Category, Weapon[]> = Map.groupBy(
+            byRarity.get(rarity) || [],
+            ({ category }) => category,
           );
           const categories = Array.from(ws2.keys());
           return `<tr><th rowspan="${categories.length}">${"⭐".repeat(rarity)}</th>
diff --git a/tsconfig.json b/tsconfig.json
index 0c1cc59..52d5add 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,7 +2,7 @@
     "compilerOptions": {
         "lib": [
             "DOM",
-            "ES2023"
+            "ESNext"
         ],
         "module": "ES2020",
         "moduleResolution": "node",