Real projects need typed browser APIs, a gradual path from JavaScript, and tools that check what the compiler cannot.
DOM manipulation
lib.dom.d.ts is generated from the web specifications (HTML DOM APIs). querySelector infers the element type from a tag name; for other selectors you pass it. Listeners get their event type from the event name. Lookups may return null and event.target is only an EventTarget, so narrow with instanceof (Narrowing):
const form = document.querySelector("form"); // HTMLFormElement | null
const qty = document.querySelector<HTMLInputElement>("#qty");
const rows = document.querySelectorAll("tr"); // NodeListOf<HTMLTableRowElement>
form.addEventListener("submit", (event) => { // event: SubmitEvent
event.preventDefault();
rows.forEach((row) => (row.hidden = row.rowIndex > (qty?.valueAsNumber ?? 0)));
});
document.addEventListener("click", ({ target }) => { // target: EventTarget | null
if (target instanceof HTMLButtonElement) target.disabled = true;
target.disabled = false;
});dom.ts(4,1): error TS18047: 'form' is possibly 'null'. dom.ts(10,3): error TS18047: 'target' is possibly 'null'. dom.ts(10,10): error TS2339: Property 'disabled' does not exist on type 'EventTarget'.
querySelector<HTMLInputElement> is an unchecked assertion, like as: if #qty is a <select>, the types lie.
Migrating from JavaScript
Migrate in small, shippable steps; types never change runtime behavior, so the app keeps working.
| Step | Setting or action | Result |
|---|---|---|
| 1. Include JavaScript | "allowJs": true, "noEmit": true | tsc sees the whole project |
| 2. Check JavaScript | // @ts-check per file, then "checkJs": true | Errors in .js, fixed with JSDoc (Declaration Files) |
| 3. Rename leaf modules | .js to .ts, utilities first | Real annotations; // @ts-nocheck parks noisy files |
| 4. Tighten | "strict": true with "noImplicitAny": false, then remove it | Full strictness, one flag at a time |
| 5. Finish | Add @types/*, drop allowJs | An all-TypeScript project |
Mark a known problem with // @ts-expect-error rather than // @ts-ignore: it fails once the line is fixed, so stale suppressions do not pile up.
Zod and type-fest
JSON from fetch or a form is really unknown. Zod 44,027 (https://github.com/colinhacks/zod 44,027 ) 4.6 (introduced in Essential JavaScript Libraries) validates such data and infers the static type from the schema, so you declare each shape once. type-fest 17,419 (https://github.com/sindresorhus/type-fest 17,419 ) 5.9 adds ready-made utility types such as SetRequired, PartialDeep, Simplify and Jsonify, all types-only:
import * as z from "zod";
import type { SetRequired } from "type-fest";
const Order = z.object({ email: z.email(), qty: z.int().positive(), note: z.string().optional() });
type Order = z.infer<typeof Order>; // { email: string; qty: number; note?: string }
export type GiftOrder = SetRequired<Order, "note">; // note: string
const result = Order.safeParse(JSON.parse('{"email":"ada@example","qty":0}'));
if (result.success) console.log(result.data.qty); // result.data: Order
else console.log(z.prettifyError(result.error));✖ Invalid email address → at email ✖ Too small: expected number to be >0 → at qty
typescript-eslint and testing types
typescript-eslint 16,401 (https://github.com/typescript-eslint/typescript-eslint 16,401 ) plugs TypeScript's parser and type checker into ESLint 39,810 (Code Quality Tools); recommendedTypeChecked enables rules that need type information, such as unhandled promises and unsafe any:
import js from "@eslint/js";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig({
files: ["**/*.ts"],
extends: [js.configs.recommended, tseslint.configs.recommendedTypeChecked],
languageOptions: { parserOptions: { projectService: true } },
});A call save(order); to an async function, without await, passes tsc but not the linter:
5:3 error Promises must be awaited, end with a call to .catch, end with a call to .then
with a rejection handler or be explicitly marked as ignored with the `void` operator
@typescript-eslint/no-floating-promisesLibrary authors also test the types themselves. expect-type 566 (https://github.com/mmkal/expect-type 566 ) turns type assertions into compile errors; Vitest 102,989 's expectTypeOf is built on it and checks *.test-d.ts files with vitest --typecheck:
import { expectTypeOf } from "expect-type";
import * as z from "zod";
const Order = z.object({ email: z.email(), qty: z.int() });
expectTypeOf<z.infer<typeof Order>>().toEqualTypeOf<{ email: string; qty: number }>();
expectTypeOf(Order.parse).returns.toEqualTypeOf<{ email: string; qty: string }>();
// @ts-expect-error: qty must be a number
Order.parse satisfies (data: unknown) => { qty: string };types.test.ts(6,49): error TS2344: Type '{ email: string; qty: string; }' does not satisfy the
constraint '{ email: string; qty: "Expected: string, Actual: number"; }'.