diff --git a/Makefile b/Makefile
index d5b779f1e59..4889958c3b6 100644
--- a/Makefile
+++ b/Makefile
@@ -377,12 +377,12 @@ lint-backend-fix: lint-go-fix lint-go-vet lint-editorconfig
 .PHONY: lint-js
 lint-js: node_modules
 	npx eslint --color --max-warnings=0 --ext js,ts,vue $(ESLINT_FILES)
-#	npx vue-tsc
+	npx vue-tsc
 
 .PHONY: lint-js-fix
 lint-js-fix: node_modules
 	npx eslint --color --max-warnings=0 --ext js,ts,vue $(ESLINT_FILES) --fix
-#	npx vue-tsc
+	npx vue-tsc
 
 .PHONY: lint-css
 lint-css: node_modules
@@ -451,10 +451,6 @@ lint-templates: .venv node_modules
 lint-yaml: .venv
 	@poetry run yamllint .
 
-.PHONY: tsc
-tsc:
-	npx vue-tsc
-
 .PHONY: watch
 watch:
 	@bash tools/watch.sh
diff --git a/package-lock.json b/package-lock.json
index 4764282f65e..8755cfe06f7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -67,6 +67,7 @@
       "devDependencies": {
         "@eslint-community/eslint-plugin-eslint-comments": "4.4.1",
         "@playwright/test": "1.49.0",
+        "@silverwind/vue-tsc": "2.1.13",
         "@stoplight/spectral-cli": "6.14.2",
         "@stylistic/eslint-plugin-js": "2.11.0",
         "@stylistic/stylelint-plugin": "3.1.1",
@@ -111,8 +112,7 @@
         "type-fest": "4.30.0",
         "updates": "16.4.0",
         "vite-string-plugin": "1.3.4",
-        "vitest": "2.1.8",
-        "vue-tsc": "2.1.10"
+        "vitest": "2.1.8"
       },
       "engines": {
         "node": ">= 18.0.0"
@@ -3833,6 +3833,24 @@
       "hasInstallScript": true,
       "license": "Apache-2.0"
     },
+    "node_modules/@silverwind/vue-tsc": {
+      "version": "2.1.13",
+      "resolved": "https://registry.npmjs.org/@silverwind/vue-tsc/-/vue-tsc-2.1.13.tgz",
+      "integrity": "sha512-ejFxz1KZiUGAESbC+eURnjqt0N95qkU9eZU7W15wgF9zV+v2FEu3ZLduuXTC7D/Sg6lL1R/QjPfUbxbAbBQOsw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/typescript": "~2.4.11",
+        "@vue/language-core": "2.1.10",
+        "semver": "^7.5.4"
+      },
+      "bin": {
+        "vue-tsc": "bin/vue-tsc.js"
+      },
+      "peerDependencies": {
+        "typescript": ">=5.0.0"
+      }
+    },
     "node_modules/@silverwind/vue3-calendar-heatmap": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/@silverwind/vue3-calendar-heatmap/-/vue3-calendar-heatmap-2.0.6.tgz",
@@ -5335,30 +5353,30 @@
       }
     },
     "node_modules/@volar/language-core": {
-      "version": "2.4.10",
-      "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.10.tgz",
-      "integrity": "sha512-hG3Z13+nJmGaT+fnQzAkS0hjJRa2FCeqZt6Bd+oGNhUkQ+mTFsDETg5rqUTxyzIh5pSOGY7FHCWUS8G82AzLCA==",
+      "version": "2.4.11",
+      "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.11.tgz",
+      "integrity": "sha512-lN2C1+ByfW9/JRPpqScuZt/4OrUUse57GLI6TbLgTIqBVemdl1wNcZ1qYGEo2+Gw8coYLgCy7SuKqn6IrQcQgg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@volar/source-map": "2.4.10"
+        "@volar/source-map": "2.4.11"
       }
     },
     "node_modules/@volar/source-map": {
-      "version": "2.4.10",
-      "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.10.tgz",
-      "integrity": "sha512-OCV+b5ihV0RF3A7vEvNyHPi4G4kFa6ukPmyVocmqm5QzOd8r5yAtiNvaPEjl8dNvgC/lj4JPryeeHLdXd62rWA==",
+      "version": "2.4.11",
+      "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.11.tgz",
+      "integrity": "sha512-ZQpmafIGvaZMn/8iuvCFGrW3smeqkq/IIh9F1SdSx9aUl0J4Iurzd6/FhmjNO5g2ejF3rT45dKskgXWiofqlZQ==",
       "dev": true,
       "license": "MIT"
     },
     "node_modules/@volar/typescript": {
-      "version": "2.4.10",
-      "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.10.tgz",
-      "integrity": "sha512-F8ZtBMhSXyYKuBfGpYwqA5rsONnOwAVvjyE7KPYJ7wgZqo2roASqNWUnianOomJX5u1cxeRooHV59N0PhvEOgw==",
+      "version": "2.4.11",
+      "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.11.tgz",
+      "integrity": "sha512-2DT+Tdh88Spp5PyPbqhyoYavYCPDsqbHLFwcUI9K1NlY1YgUJvujGdrqUp0zWxnW7KWNTr3xSpMuv2WnaTKDAw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@volar/language-core": "2.4.10",
+        "@volar/language-core": "2.4.11",
         "path-browserify": "^1.0.1",
         "vscode-uri": "^3.0.8"
       }
@@ -15780,24 +15798,6 @@
         }
       }
     },
-    "node_modules/vue-tsc": {
-      "version": "2.1.10",
-      "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.1.10.tgz",
-      "integrity": "sha512-RBNSfaaRHcN5uqVqJSZh++Gy/YUzryuv9u1aFWhsammDJXNtUiJMNoJ747lZcQ68wUQFx6E73y4FY3D8E7FGMA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@volar/typescript": "~2.4.8",
-        "@vue/language-core": "2.1.10",
-        "semver": "^7.5.4"
-      },
-      "bin": {
-        "vue-tsc": "bin/vue-tsc.js"
-      },
-      "peerDependencies": {
-        "typescript": ">=5.0.0"
-      }
-    },
     "node_modules/watchpack": {
       "version": "2.4.2",
       "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",
diff --git a/package.json b/package.json
index 275ca898e29..61e65c1f43e 100644
--- a/package.json
+++ b/package.json
@@ -66,6 +66,7 @@
   "devDependencies": {
     "@eslint-community/eslint-plugin-eslint-comments": "4.4.1",
     "@playwright/test": "1.49.0",
+    "@silverwind/vue-tsc": "2.1.13",
     "@stoplight/spectral-cli": "6.14.2",
     "@stylistic/eslint-plugin-js": "2.11.0",
     "@stylistic/stylelint-plugin": "3.1.1",
@@ -110,8 +111,7 @@
     "type-fest": "4.30.0",
     "updates": "16.4.0",
     "vite-string-plugin": "1.3.4",
-    "vitest": "2.1.8",
-    "vue-tsc": "2.1.10"
+    "vitest": "2.1.8"
   },
   "browserslist": [
     "defaults"
diff --git a/tsconfig.json b/tsconfig.json
index e006535c02c..7d0316db299 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -7,7 +7,8 @@
   ],
   "compilerOptions": {
     "target": "es2020",
-    "module": "nodenext",
+    "module": "esnext",
+    "moduleResolution": "bundler",
     "lib": ["dom", "dom.iterable", "dom.asynciterable", "esnext"],
     "allowImportingTsExtensions": true,
     "allowJs": true,
diff --git a/web_src/js/features/common-issue-list.ts b/web_src/js/features/common-issue-list.ts
index e8a47eabad6..e2073647940 100644
--- a/web_src/js/features/common-issue-list.ts
+++ b/web_src/js/features/common-issue-list.ts
@@ -7,7 +7,7 @@ const reIssueSharpIndex = /^#(\d+)$/; // eg: "#123"
 const reIssueOwnerRepoIndex = /^([-.\w]+)\/([-.\w]+)#(\d+)$/;  // eg: "{owner}/{repo}#{index}"
 
 // if the searchText can be parsed to an "issue goto link", return the link, otherwise return empty string
-export function parseIssueListQuickGotoLink(repoLink, searchText) {
+export function parseIssueListQuickGotoLink(repoLink: string, searchText: string) {
   searchText = searchText.trim();
   let targetUrl = '';
   if (repoLink) {
@@ -15,13 +15,12 @@ export function parseIssueListQuickGotoLink(repoLink, searchText) {
     if (reIssueIndex.test(searchText)) {
       targetUrl = `${repoLink}/issues/${searchText}`;
     } else if (reIssueSharpIndex.test(searchText)) {
-      targetUrl = `${repoLink}/issues/${searchText.substr(1)}`;
+      targetUrl = `${repoLink}/issues/${searchText.substring(1)}`;
     }
   } else {
     // try to parse it for a global search (eg: "owner/repo#123")
-    const matchIssueOwnerRepoIndex = searchText.match(reIssueOwnerRepoIndex);
-    if (matchIssueOwnerRepoIndex) {
-      const [_, owner, repo, index] = matchIssueOwnerRepoIndex;
+    const [_, owner, repo, index] = reIssueOwnerRepoIndex.exec(searchText) || [];
+    if (owner) {
       targetUrl = `${appSubUrl}/${owner}/${repo}/issues/${index}`;
     }
   }
@@ -33,7 +32,7 @@ export function initCommonIssueListQuickGoto() {
   if (!goto) return;
 
   const form = goto.closest('form');
-  const input = form.querySelector('input[name=q]');
+  const input = form.querySelector<HTMLInputElement>('input[name=q]');
   const repoLink = goto.getAttribute('data-repo-link');
 
   form.addEventListener('submit', (e) => {
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts
index 80eabaa37ae..bba50a1296f 100644
--- a/web_src/js/features/comp/ComboMarkdownEditor.ts
+++ b/web_src/js/features/comp/ComboMarkdownEditor.ts
@@ -283,8 +283,8 @@ export class ComboMarkdownEditor {
     ];
   }
 
-  parseEasyMDEToolbar(EasyMDE, actions) {
-    this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(EasyMDE, this);
+  parseEasyMDEToolbar(easyMde: typeof EasyMDE, actions) {
+    this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(easyMde, this);
     const processed = [];
     for (const action of actions) {
       const actionButton = this.easyMDEToolbarActions[action];
diff --git a/web_src/js/features/comp/EasyMDEToolbarActions.ts b/web_src/js/features/comp/EasyMDEToolbarActions.ts
index d91dd23d115..ec5c7304bef 100644
--- a/web_src/js/features/comp/EasyMDEToolbarActions.ts
+++ b/web_src/js/features/comp/EasyMDEToolbarActions.ts
@@ -1,100 +1,102 @@
 import {svg} from '../../svg.ts';
+import type EasyMDE from 'easymde';
+import type {ComboMarkdownEditor} from './ComboMarkdownEditor.ts';
 
-export function easyMDEToolbarActions(EasyMDE, editor) {
-  const actions = {
+export function easyMDEToolbarActions(easyMde: typeof EasyMDE, editor: ComboMarkdownEditor): Record<string, Partial<EasyMDE.ToolbarIcon | string>> {
+  const actions: Record<string, Partial<EasyMDE.ToolbarIcon> | string> = {
     '|': '|',
     'heading-1': {
-      action: EasyMDE.toggleHeading1,
+      action: easyMde.toggleHeading1,
       icon: svg('octicon-heading'),
       title: 'Heading 1',
     },
     'heading-2': {
-      action: EasyMDE.toggleHeading2,
+      action: easyMde.toggleHeading2,
       icon: svg('octicon-heading'),
       title: 'Heading 2',
     },
     'heading-3': {
-      action: EasyMDE.toggleHeading3,
+      action: easyMde.toggleHeading3,
       icon: svg('octicon-heading'),
       title: 'Heading 3',
     },
     'heading-smaller': {
-      action: EasyMDE.toggleHeadingSmaller,
+      action: easyMde.toggleHeadingSmaller,
       icon: svg('octicon-heading'),
       title: 'Decrease Heading',
     },
     'heading-bigger': {
-      action: EasyMDE.toggleHeadingBigger,
+      action: easyMde.toggleHeadingBigger,
       icon: svg('octicon-heading'),
       title: 'Increase Heading',
     },
     'bold': {
-      action: EasyMDE.toggleBold,
+      action: easyMde.toggleBold,
       icon: svg('octicon-bold'),
       title: 'Bold',
     },
     'italic': {
-      action: EasyMDE.toggleItalic,
+      action: easyMde.toggleItalic,
       icon: svg('octicon-italic'),
       title: 'Italic',
     },
     'strikethrough': {
-      action: EasyMDE.toggleStrikethrough,
+      action: easyMde.toggleStrikethrough,
       icon: svg('octicon-strikethrough'),
       title: 'Strikethrough',
     },
     'quote': {
-      action: EasyMDE.toggleBlockquote,
+      action: easyMde.toggleBlockquote,
       icon: svg('octicon-quote'),
       title: 'Quote',
     },
     'code': {
-      action: EasyMDE.toggleCodeBlock,
+      action: easyMde.toggleCodeBlock,
       icon: svg('octicon-code'),
       title: 'Code',
     },
     'link': {
-      action: EasyMDE.drawLink,
+      action: easyMde.drawLink,
       icon: svg('octicon-link'),
       title: 'Link',
     },
     'unordered-list': {
-      action: EasyMDE.toggleUnorderedList,
+      action: easyMde.toggleUnorderedList,
       icon: svg('octicon-list-unordered'),
       title: 'Unordered List',
     },
     'ordered-list': {
-      action: EasyMDE.toggleOrderedList,
+      action: easyMde.toggleOrderedList,
       icon: svg('octicon-list-ordered'),
       title: 'Ordered List',
     },
     'image': {
-      action: EasyMDE.drawImage,
+      action: easyMde.drawImage,
       icon: svg('octicon-image'),
       title: 'Image',
     },
     'table': {
-      action: EasyMDE.drawTable,
+      action: easyMde.drawTable,
       icon: svg('octicon-table'),
       title: 'Table',
     },
     'horizontal-rule': {
-      action: EasyMDE.drawHorizontalRule,
+      action: easyMde.drawHorizontalRule,
       icon: svg('octicon-horizontal-rule'),
       title: 'Horizontal Rule',
     },
     'preview': {
-      action: EasyMDE.togglePreview,
+      action: easyMde.togglePreview,
       icon: svg('octicon-eye'),
       title: 'Preview',
     },
     'fullscreen': {
-      action: EasyMDE.toggleFullScreen,
+      action: easyMde.toggleFullScreen,
       icon: svg('octicon-screen-full'),
       title: 'Fullscreen',
     },
     'side-by-side': {
-      action: EasyMDE.toggleSideBySide,
+      action: easyMde.toggleSideBySide,
       icon: svg('octicon-columns'),
       title: 'Side by Side',
     },
diff --git a/web_src/js/features/comp/ReactionSelector.ts b/web_src/js/features/comp/ReactionSelector.ts
index 1e955c7ab40..671bade3bec 100644
--- a/web_src/js/features/comp/ReactionSelector.ts
+++ b/web_src/js/features/comp/ReactionSelector.ts
@@ -3,7 +3,7 @@ import {fomanticQuery} from '../../modules/fomantic/base.ts';
 
 export function initCompReactionSelector(parent: ParentNode = document) {
   for (const container of parent.querySelectorAll('.issue-content, .diff-file-body')) {
-    container.addEventListener('click', async (e) => {
+    container.addEventListener('click', async (e: MouseEvent & {target: HTMLElement}) => {
       // there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment
       const target = e.target.closest('.comment-reaction-button');
       if (!target) return;
diff --git a/web_src/js/features/comp/WebHookEditor.ts b/web_src/js/features/comp/WebHookEditor.ts
index b13a2ffca3d..203396af80a 100644
--- a/web_src/js/features/comp/WebHookEditor.ts
+++ b/web_src/js/features/comp/WebHookEditor.ts
@@ -23,7 +23,7 @@ export function initCompWebHookEditor() {
   }
 
   // some webhooks (like Gitea) allow to set the request method (GET/POST), and it would toggle the "Content Type" field
-  const httpMethodInput = document.querySelector('#http_method');
+  const httpMethodInput = document.querySelector<HTMLInputElement>('#http_method');
   if (httpMethodInput) {
     const updateContentType = function () {
       const visible = httpMethodInput.value === 'POST';
diff --git a/web_src/js/features/dropzone.ts b/web_src/js/features/dropzone.ts
index c9b0149df58..666c6452304 100644
--- a/web_src/js/features/dropzone.ts
+++ b/web_src/js/features/dropzone.ts
@@ -6,6 +6,7 @@ import {GET, POST} from '../modules/fetch.ts';
 import {showErrorToast} from '../modules/toast.ts';
 import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts';
 import {isImageFile, isVideoFile} from '../utils.ts';
+import type {DropzoneFile} from 'dropzone/index.js';
 
 const {csrfToken, i18n} = window.config;
 
@@ -15,14 +16,14 @@ export const DropzoneCustomEventRemovedFile = 'dropzone-custom-removed-file';
 export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done';
 
 async function createDropzone(el, opts) {
-  const [{Dropzone}] = await Promise.all([
+  const [{default: Dropzone}] = await Promise.all([
     import(/* webpackChunkName: "dropzone" */'dropzone'),
     import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'),
   ]);
   return new Dropzone(el, opts);
 }
 
-export function generateMarkdownLinkForAttachment(file, {width, dppx} = {}) {
+export function generateMarkdownLinkForAttachment(file, {width, dppx}: {width?: number, dppx?: number} = {}) {
   let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
   if (isImageFile(file)) {
     fileMarkdown = `!${fileMarkdown}`;
@@ -60,14 +61,14 @@ function addCopyLink(file) {
 /**
  * @param {HTMLElement} dropzoneEl
  */
-export async function initDropzone(dropzoneEl) {
+export async function initDropzone(dropzoneEl: HTMLElement) {
   const listAttachmentsUrl = dropzoneEl.closest('[data-attachment-url]')?.getAttribute('data-attachment-url');
   const removeAttachmentUrl = dropzoneEl.getAttribute('data-remove-url');
   const attachmentBaseLinkUrl = dropzoneEl.getAttribute('data-link-url');
 
   let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event
   let fileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone
-  const opts = {
+  const opts: Record<string, any> = {
     url: dropzoneEl.getAttribute('data-upload-url'),
     headers: {'X-Csrf-Token': csrfToken},
     acceptedFiles: ['*/*', ''].includes(dropzoneEl.getAttribute('data-accepts')) ? null : dropzoneEl.getAttribute('data-accepts'),
@@ -88,7 +89,7 @@ export async function initDropzone(dropzoneEl) {
   // "http://localhost:3000/owner/repo/issues/[object%20Event]"
   // the reason is that the preview "callback(dataURL)" is assign to "img.onerror" then "thumbnail" uses the error object as the dataURL and generates '<img src="[object Event]">'
   const dzInst = await createDropzone(dropzoneEl, opts);
-  dzInst.on('success', (file, resp) => {
+  dzInst.on('success', (file: DropzoneFile & {uuid: string}, resp: any) => {
     file.uuid = resp.uuid;
     fileUuidDict[file.uuid] = {submitted: false};
     const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${resp.uuid}`, value: resp.uuid});
@@ -97,7 +98,7 @@ export async function initDropzone(dropzoneEl) {
     dzInst.emit(DropzoneCustomEventUploadDone, {file});
   });
 
-  dzInst.on('removedfile', async (file) => {
+  dzInst.on('removedfile', async (file: DropzoneFile & {uuid: string}) => {
     if (disableRemovedfileEvent) return;
 
     dzInst.emit(DropzoneCustomEventRemovedFile, {fileUuid: file.uuid});
diff --git a/web_src/js/features/emoji.ts b/web_src/js/features/emoji.ts
index 032a3efe8aa..933aa951c54 100644
--- a/web_src/js/features/emoji.ts
+++ b/web_src/js/features/emoji.ts
@@ -1,4 +1,4 @@
-import emojis from '../../../assets/emoji.json';
+import emojis from '../../../assets/emoji.json' with {type: 'json'};
 
 const {assetUrlPrefix, customEmojis} = window.config;
 
diff --git a/web_src/js/features/eventsource.sharedworker.ts b/web_src/js/features/eventsource.sharedworker.ts
index 62581cf687d..991c92cc8e8 100644
--- a/web_src/js/features/eventsource.sharedworker.ts
+++ b/web_src/js/features/eventsource.sharedworker.ts
@@ -2,6 +2,11 @@ const sourcesByUrl = {};
 const sourcesByPort = {};
 
 class Source {
+  url: string;
+  eventSource: EventSource;
+  listening: Record<string, any>;
+  clients: Array<any>;
+
   constructor(url) {
     this.url = url;
     this.eventSource = new EventSource(url);
@@ -67,7 +72,7 @@ class Source {
   }
 }
 
-self.addEventListener('connect', (e) => {
+self.addEventListener('connect', (e: Event & {ports: Array<any>}) => {
   for (const port of e.ports) {
     port.addEventListener('message', (event) => {
       if (!self.EventSource) {
diff --git a/web_src/js/features/heatmap.ts b/web_src/js/features/heatmap.ts
index 69cd069a94f..53eebc93e55 100644
--- a/web_src/js/features/heatmap.ts
+++ b/web_src/js/features/heatmap.ts
@@ -21,8 +21,8 @@ export function initHeatmap() {
     // last heatmap tooltip localization attempt https://github.com/go-gitea/gitea/pull/24131/commits/a83761cbbae3c2e3b4bced71e680f44432073ac8
     const locale = {
       heatMapLocale: {
-        months: new Array(12).fill().map((_, idx) => translateMonth(idx)),
-        days: new Array(7).fill().map((_, idx) => translateDay(idx)),
+        months: new Array(12).fill(undefined).map((_, idx) => translateMonth(idx)),
+        days: new Array(7).fill(undefined).map((_, idx) => translateDay(idx)),
         on: ' - ', // no correct locale support for it, because in many languages the sentence is not "something on someday"
         more: el.getAttribute('data-locale-more'),
         less: el.getAttribute('data-locale-less'),
diff --git a/web_src/js/features/install.ts b/web_src/js/features/install.ts
index 3defb7904aa..725dcafab08 100644
--- a/web_src/js/features/install.ts
+++ b/web_src/js/features/install.ts
@@ -22,9 +22,9 @@ function initPreInstall() {
     mssql: '127.0.0.1:1433',
   };
 
-  const dbHost = document.querySelector('#db_host');
-  const dbUser = document.querySelector('#db_user');
-  const dbName = document.querySelector('#db_name');
+  const dbHost = document.querySelector<HTMLInputElement>('#db_host');
+  const dbUser = document.querySelector<HTMLInputElement>('#db_user');
+  const dbName = document.querySelector<HTMLInputElement>('#db_name');
 
   // Database type change detection.
   document.querySelector('#db_type').addEventListener('change', function () {
@@ -48,12 +48,12 @@ function initPreInstall() {
   });
   document.querySelector('#db_type').dispatchEvent(new Event('change'));
 
-  const appUrl = document.querySelector('#app_url');
+  const appUrl = document.querySelector<HTMLInputElement>('#app_url');
   if (appUrl.value.includes('://localhost')) {
     appUrl.value = window.location.href;
   }
 
-  const domain = document.querySelector('#domain');
+  const domain = document.querySelector<HTMLInputElement>('#domain');
   if (domain.value.trim() === 'localhost') {
     domain.value = window.location.hostname;
   }
@@ -61,43 +61,43 @@ function initPreInstall() {
   // TODO: better handling of exclusive relations.
   document.querySelector('#offline-mode input').addEventListener('change', function () {
     if (this.checked) {
-      document.querySelector('#disable-gravatar input').checked = true;
-      document.querySelector('#federated-avatar-lookup input').checked = false;
+      document.querySelector<HTMLInputElement>('#disable-gravatar input').checked = true;
+      document.querySelector<HTMLInputElement>('#federated-avatar-lookup input').checked = false;
     }
   });
   document.querySelector('#disable-gravatar input').addEventListener('change', function () {
     if (this.checked) {
-      document.querySelector('#federated-avatar-lookup input').checked = false;
+      document.querySelector<HTMLInputElement>('#federated-avatar-lookup input').checked = false;
     } else {
-      document.querySelector('#offline-mode input').checked = false;
+      document.querySelector<HTMLInputElement>('#offline-mode input').checked = false;
     }
   });
   document.querySelector('#federated-avatar-lookup input').addEventListener('change', function () {
     if (this.checked) {
-      document.querySelector('#disable-gravatar input').checked = false;
-      document.querySelector('#offline-mode input').checked = false;
+      document.querySelector<HTMLInputElement>('#disable-gravatar input').checked = false;
+      document.querySelector<HTMLInputElement>('#offline-mode input').checked = false;
     }
   });
   document.querySelector('#enable-openid-signin input').addEventListener('change', function () {
     if (this.checked) {
-      if (!document.querySelector('#disable-registration input').checked) {
-        document.querySelector('#enable-openid-signup input').checked = true;
+      if (!document.querySelector<HTMLInputElement>('#disable-registration input').checked) {
+        document.querySelector<HTMLInputElement>('#enable-openid-signup input').checked = true;
       }
     } else {
-      document.querySelector('#enable-openid-signup input').checked = false;
+      document.querySelector<HTMLInputElement>('#enable-openid-signup input').checked = false;
     }
   });
   document.querySelector('#disable-registration input').addEventListener('change', function () {
     if (this.checked) {
-      document.querySelector('#enable-captcha input').checked = false;
-      document.querySelector('#enable-openid-signup input').checked = false;
+      document.querySelector<HTMLInputElement>('#enable-captcha input').checked = false;
+      document.querySelector<HTMLInputElement>('#enable-openid-signup input').checked = false;
     } else {
-      document.querySelector('#enable-openid-signup input').checked = true;
+      document.querySelector<HTMLInputElement>('#enable-openid-signup input').checked = true;
     }
   });
   document.querySelector('#enable-captcha input').addEventListener('change', function () {
     if (this.checked) {
-      document.querySelector('#disable-registration input').checked = false;
+      document.querySelector<HTMLInputElement>('#disable-registration input').checked = false;
     }
   });
 }
diff --git a/web_src/js/features/notification.ts b/web_src/js/features/notification.ts
index 539f7790561..5cdcd967f06 100644
--- a/web_src/js/features/notification.ts
+++ b/web_src/js/features/notification.ts
@@ -14,25 +14,25 @@ export function initNotificationsTable() {
   window.addEventListener('pageshow', (e) => {
     if (e.persisted) { // page was restored from bfcache
       const table = document.querySelector('#notification_table');
-      const unreadCountEl = document.querySelector('.notifications-unread-count');
+      const unreadCountEl = document.querySelector<HTMLElement>('.notifications-unread-count');
       let unreadCount = parseInt(unreadCountEl.textContent);
       for (const item of table.querySelectorAll('.notifications-item[data-remove="true"]')) {
         item.remove();
         unreadCount -= 1;
       }
-      unreadCountEl.textContent = unreadCount;
+      unreadCountEl.textContent = String(unreadCount);
     }
   });
 
   // mark clicked unread links for deletion on bfcache restore
   for (const link of table.querySelectorAll('.notifications-item[data-status="1"] .notifications-link')) {
-    link.addEventListener('click', (e) => {
+    link.addEventListener('click', (e : MouseEvent & {target: HTMLElement}) => {
       e.target.closest('.notifications-item').setAttribute('data-remove', 'true');
     });
   }
 }
 
-async function receiveUpdateCount(event) {
+async function receiveUpdateCount(event: MessageEvent) {
   try {
     const data = JSON.parse(event.data);
 
@@ -50,7 +50,7 @@ export function initNotificationCount() {
   if (!document.querySelector('.notification_count')) return;
 
   let usingPeriodicPoller = false;
-  const startPeriodicPoller = (timeout, lastCount) => {
+  const startPeriodicPoller = (timeout: number, lastCount?: number) => {
     if (timeout <= 0 || !Number.isFinite(timeout)) return;
     usingPeriodicPoller = true;
     lastCount = lastCount ?? getCurrentCount();
@@ -72,13 +72,13 @@ export function initNotificationCount() {
       type: 'start',
       url: `${window.location.origin}${appSubUrl}/user/events`,
     });
-    worker.port.addEventListener('message', (event) => {
+    worker.port.addEventListener('message', (event: MessageEvent) => {
       if (!event.data || !event.data.type) {
         console.error('unknown worker message event', event);
         return;
       }
       if (event.data.type === 'notification-count') {
-        const _promise = receiveUpdateCount(event.data);
+        receiveUpdateCount(event); // no await
       } else if (event.data.type === 'no-event-source') {
         // browser doesn't support EventSource, falling back to periodic poller
         if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
@@ -118,10 +118,10 @@ export function initNotificationCount() {
 }
 
 function getCurrentCount() {
-  return document.querySelector('.notification_count').textContent;
+  return Number(document.querySelector('.notification_count').textContent ?? '0');
 }
 
-async function updateNotificationCountWithCallback(callback, timeout, lastCount) {
+async function updateNotificationCountWithCallback(callback: (timeout: number, newCount: number) => void, timeout: number, lastCount: number) {
   const currentCount = getCurrentCount();
   if (lastCount !== currentCount) {
     callback(notificationSettings.MinTimeout, currentCount);
@@ -149,10 +149,9 @@ async function updateNotificationTable() {
   if (notificationDiv) {
     try {
       const params = new URLSearchParams(window.location.search);
-      params.set('div-only', true);
-      params.set('sequence-number', ++notificationSequenceNumber);
-      const url = `${appSubUrl}/notifications?${params.toString()}`;
-      const response = await GET(url);
+      params.set('div-only', String(true));
+      params.set('sequence-number', String(++notificationSequenceNumber));
+      const response = await GET(`${appSubUrl}/notifications?${params.toString()}`);
 
       if (!response.ok) {
         throw new Error('Failed to fetch notification table');
@@ -169,7 +168,7 @@ async function updateNotificationTable() {
   }
 }
 
-async function updateNotificationCount() {
+async function updateNotificationCount(): Promise<number> {
   try {
     const response = await GET(`${appSubUrl}/notifications/new`);
 
@@ -185,9 +184,9 @@ async function updateNotificationCount() {
       el.textContent = `${data.new}`;
     }
 
-    return `${data.new}`;
+    return data.new as number;
   } catch (error) {
     console.error(error);
-    return '0';
+    return 0;
   }
 }
diff --git a/web_src/js/features/oauth2-settings.ts b/web_src/js/features/oauth2-settings.ts
index 1e62ca00964..a206bc8912f 100644
--- a/web_src/js/features/oauth2-settings.ts
+++ b/web_src/js/features/oauth2-settings.ts
@@ -1,5 +1,7 @@
 export function initOAuth2SettingsDisableCheckbox() {
-  for (const e of document.querySelectorAll('.disable-setting')) e.addEventListener('change', ({target}) => {
-    document.querySelector(e.getAttribute('data-target')).classList.toggle('disabled', target.checked);
-  });
+  for (const el of document.querySelectorAll('.disable-setting')) {
+    el.addEventListener('change', (e: Event & {target: HTMLInputElement}) => {
+      document.querySelector(e.target.getAttribute('data-target')).classList.toggle('disabled', e.target.checked);
+    });
+  }
 }
diff --git a/web_src/js/features/pull-view-file.ts b/web_src/js/features/pull-view-file.ts
index 9a052207d50..36fe4bc4dfb 100644
--- a/web_src/js/features/pull-view-file.ts
+++ b/web_src/js/features/pull-view-file.ts
@@ -34,7 +34,7 @@ export function countAndUpdateViewedFiles() {
 export function initViewedCheckboxListenerFor() {
   for (const form of document.querySelectorAll(`${viewedCheckboxSelector}:not([data-has-viewed-checkbox-listener="true"])`)) {
     // To prevent double addition of listeners
-    form.setAttribute('data-has-viewed-checkbox-listener', true);
+    form.setAttribute('data-has-viewed-checkbox-listener', String(true));
 
     // The checkbox consists of a div containing the real checkbox with its label and the CSRF token,
     // hence the actual checkbox first has to be found
@@ -67,7 +67,7 @@ export function initViewedCheckboxListenerFor() {
       // Unfortunately, actual forms cause too many problems, hence another approach is needed
       const files = {};
       files[fileName] = this.checked;
-      const data = {files};
+      const data: Record<string, any> = {files};
       const headCommitSHA = form.getAttribute('data-headcommit');
       if (headCommitSHA) data.headCommitSHA = headCommitSHA;
       POST(form.getAttribute('data-link'), {data});
diff --git a/web_src/js/features/repo-editor.ts b/web_src/js/features/repo-editor.ts
index 96b08250fb1..32d0b84f4c9 100644
--- a/web_src/js/features/repo-editor.ts
+++ b/web_src/js/features/repo-editor.ts
@@ -35,7 +35,7 @@ function initEditPreviewTab(elForm: HTMLFormElement) {
 }
 
 export function initRepoEditor() {
-  const dropzoneUpload = document.querySelector('.page-content.repository.editor.upload .dropzone');
+  const dropzoneUpload = document.querySelector<HTMLElement>('.page-content.repository.editor.upload .dropzone');
   if (dropzoneUpload) initDropzone(dropzoneUpload);
 
   const editArea = document.querySelector<HTMLTextAreaElement>('.page-content.repository.editor textarea#edit_area');
diff --git a/web_src/js/features/repo-search.ts b/web_src/js/features/repo-search.ts
index 9cc2dd42239..7f111dce33f 100644
--- a/web_src/js/features/repo-search.ts
+++ b/web_src/js/features/repo-search.ts
@@ -5,9 +5,10 @@ export function initRepositorySearch() {
   repositorySearchForm.addEventListener('change', (e: Event & {target: HTMLFormElement}) => {
     e.preventDefault();
 
-    const formData = new FormData(repositorySearchForm);
-    const params = new URLSearchParams(formData);
-
+    const params = new URLSearchParams();
+    for (const [key, value] of new FormData(repositorySearchForm).entries()) {
+      params.set(key, value.toString());
+    }
     if (e.target.name === 'clear-filter') {
       params.delete('archived');
       params.delete('fork');
diff --git a/web_src/js/features/repo-settings-branches.test.ts b/web_src/js/features/repo-settings-branches.test.ts
index c4609999bef..32ab54e4c2c 100644
--- a/web_src/js/features/repo-settings-branches.test.ts
+++ b/web_src/js/features/repo-settings-branches.test.ts
@@ -2,6 +2,7 @@ import {beforeEach, describe, expect, test, vi} from 'vitest';
 import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
 import {POST} from '../modules/fetch.ts';
 import {createSortable} from '../modules/sortable.ts';
+import type {SortableEvent} from 'sortablejs';
 
 vi.mock('../modules/fetch.ts', () => ({
   POST: vi.fn(),
@@ -54,8 +55,8 @@ describe('Repository Branch Settings', () => {
     vi.mocked(POST).mockResolvedValue({ok: true} as Response);
 
     // Mock createSortable to capture and execute the onEnd callback
-    vi.mocked(createSortable).mockImplementation((_el, options) => {
-      options.onEnd();
+    vi.mocked(createSortable).mockImplementation(async (_el: Element, options) => {
+      options.onEnd(new Event('SortableEvent') as SortableEvent);
       return {destroy: vi.fn()};
     });
 
diff --git a/web_src/js/features/tribute.ts b/web_src/js/features/tribute.ts
index 44588c00646..fa65bcbb280 100644
--- a/web_src/js/features/tribute.ts
+++ b/web_src/js/features/tribute.ts
@@ -51,6 +51,7 @@ function makeCollections({mentions, emoji}) {
 export async function attachTribute(element, {mentions, emoji}) {
   const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs');
   const collections = makeCollections({mentions, emoji});
+  // @ts-expect-error TS2351: This expression is not constructable (strange, why)
   const tribute = new Tribute({collection: collections, noMatchTemplate: ''});
   tribute.attach(element);
   return tribute;
diff --git a/web_src/js/globals.d.ts b/web_src/js/globals.d.ts
index 9780a1cf3cf..a5ec29a83f6 100644
--- a/web_src/js/globals.d.ts
+++ b/web_src/js/globals.d.ts
@@ -8,6 +8,17 @@ declare module '*.css' {
   export default value;
 }
 
+declare module '*.vue' {
+  import type {DefineComponent} from 'vue';
+  const component: DefineComponent<unknown, unknown, any>;
+  export default component;
+  // List of named exports from vue components, used to make `tsc` output clean.
+  // To actually lint .vue files, `vue-tsc` is used because `tsc` can not parse them.
+  export function initRepoBranchTagSelector(selector: string): void;
+  export function initDashboardRepoList(): void;
+  export function initRepositoryActionView(): void;
+}
+
 declare let __webpack_public_path__: string;
 
 declare module 'htmx.org/dist/htmx.esm.js' {
@@ -16,8 +27,8 @@ declare module 'htmx.org/dist/htmx.esm.js' {
 }
 
 declare module 'uint8-to-base64' {
-  export function encode(arrayBuffer: ArrayBuffer): string;
-  export function decode(base64str: string): ArrayBuffer;
+  export function encode(arrayBuffer: Uint8Array): string;
+  export function decode(base64str: string): Uint8Array;
 }
 
 declare module 'swagger-ui-dist/swagger-ui-es-bundle.js' {
diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts
index ce0b3cbc398..4e7f1ac0933 100644
--- a/web_src/js/modules/tippy.ts
+++ b/web_src/js/modules/tippy.ts
@@ -16,7 +16,6 @@ export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
   // because we should use our own wrapper functions to handle them, do not let the user override them
   const {onHide, onShow, onDestroy, role, theme, arrow, ...other} = opts;
 
-  // @ts-expect-error: wrong type derived by typescript
   const instance: Instance = tippy(target, {
     appendTo: document.body,
     animation: false,
diff --git a/web_src/js/utils.ts b/web_src/js/utils.ts
index bd872f094ca..997a4d1ff3f 100644
--- a/web_src/js/utils.ts
+++ b/web_src/js/utils.ts
@@ -134,16 +134,16 @@ export function toAbsoluteUrl(url: string): string {
   return `${window.location.origin}${url}`;
 }
 
-// Encode an ArrayBuffer into a URLEncoded base64 string.
-export function encodeURLEncodedBase64(arrayBuffer: ArrayBuffer): string {
-  return encode(arrayBuffer)
+// Encode an Uint8Array into a URLEncoded base64 string.
+export function encodeURLEncodedBase64(uint8Array: Uint8Array): string {
+  return encode(uint8Array)
     .replace(/\+/g, '-')
     .replace(/\//g, '_')
     .replace(/=/g, '');
 }
 
-// Decode a URLEncoded base64 to an ArrayBuffer.
-export function decodeURLEncodedBase64(base64url: string): ArrayBuffer {
+// Decode a URLEncoded base64 to an Uint8Array.
+export function decodeURLEncodedBase64(base64url: string): Uint8Array {
   return decode(base64url
     .replace(/_/g, '/')
     .replace(/-/g, '+'));