Skip to main content

TypeScript in Practice

You know the type system — now let's use it the way real projects do. This is the practical stuff I set up on every MERN codebase.

Strict Mode Flags Worth Knowing

"strict": true in tsconfig.json enables a family of flags. Three of them do most of the heavy lifting:

strictNullChecks

The single most valuable flag in TypeScript. Without it, null and undefined are assignable to anything — which means the compiler ignores the most common crash in JavaScript:

function getUpper(name: string | null): string {
return name.toUpperCase();
// ❌ with strictNullChecks: 'name' is possibly 'null'
// without it: compiles fine, crashes at runtime
}

function getUpperSafe(name: string | null): string {
return name ? name.toUpperCase() : "";
}

noImplicitAny

Without this flag, an unannotated parameter silently becomes any and the checker gives up on it:

function total(items) {
// ❌ with noImplicitAny: Parameter 'items' implicitly has an 'any' type
return items.reduce((sum, i) => sum + i.price, 0);
}

The fix is just writing the type you meant: items: { price: number }[].

noUncheckedIndexedAccess

Not included in strict, but I enable it on new projects. It makes array and index-signature access return "the type or undefined", because the element might not exist:

const names = ["Rizwan", "Sara"];

const third = names[2]; // type: string | undefined
third.toUpperCase(); // ❌ possibly 'undefined'
names[2]?.toUpperCase(); // ✅

Slightly annoying, honestly — but it has caught real off-by-one bugs for me.

Adopting strictness gradually

On a new project, start fully strict from day one — it's free. On an existing JS codebase being migrated, enable flags one at a time and fix errors incrementally. Going from zero to full strict on a large codebase in one PR is a recipe for a 400-file diff nobody can review.

Typing an Express Route Handler

Express is the classic case of "types from a package". Install the runtime package and its types:

npm install express
npm install --save-dev @types/express

The Request type takes generics for route params, response body, and request body — in that order. Typing them turns req.params and req.body from any into checked shapes:

import express, { Request, Response } from "express";

const app = express();
app.use(express.json());

interface CreateUserBody {
name: string;
email: string;
}

interface UserResponse {
id: number;
name: string;
email: string;
}

app.post(
"/users",
(req: Request<{}, UserResponse, CreateUserBody>, res: Response<UserResponse>) => {
const { name, email } = req.body; // ✅ typed as string
res.status(201).json({ id: 1, name, email });
}
);

app.get("/users/:id", (req: Request<{ id: string }>, res: Response) => {
const userId = Number(req.params.id); // params are always strings!
res.json({ id: userId });
});

app.listen(3000);

Two things worth internalizing:

  • Route params are always strings:id arrives as "42", not 42. The type reminds you to convert.
  • Types don't validate at runtime. CreateUserBody describes what you expect, but a client can still send garbage. Types plus a runtime validator (Zod is my pick) at the boundary is the production pattern.

Typing React Props and State

With React the file extension becomes .tsx. Props are just an interface, and useState is a generic:

import { useState } from "react";

interface ButtonProps {
label: string;
variant?: "primary" | "secondary";
onClick: () => void;
}

function Button({ label, variant = "primary", onClick }: ButtonProps) {
return (
<button className={`btn btn-${variant}`} onClick={onClick}>
{label}
</button>
);
}

interface User {
id: number;
name: string;
}

function UserPanel() {
const [user, setUser] = useState<User | null>(null);
const [count, setCount] = useState(0); // inferred as number

return (
<div>
<p>{user ? user.name : "No user loaded"}</p>
<Button label="Load" onClick={() => setUser({ id: 1, name: "Rizwan" })} />
<Button label="Clicked" variant="secondary" onClick={() => setCount(count + 1)} />
</div>
);
}

Notice useState<User | null>(null) — when the initial value doesn't reveal the full type, pass the type parameter explicitly. And that variant prop is a literal union: pass "primry" and the compiler catches the typo. Typed props are the best documentation a component can have — your editor autocompletes every prop and flags every mistake at the call site.

Type Assertions — When and When Not

An assertion (as) tells the compiler "trust me, I know the type better than you do":

const input = document.getElementById("email") as HTMLInputElement;
console.log(input.value); // ✅ 'value' exists on HTMLInputElement

Legitimate uses:

  • DOM lookups — you know #email is an input; the compiler only knows it's a generic element
  • Narrowing JSON you've already validated at a boundary
  • as const — freezing literals so they keep their literal types:
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"

The danger: an assertion silences the checker without checking anything.

const user = {} as { name: string };
console.log(user.name.length); // compiles ✅ ... crashes at runtime ❌

My rule: reach for narrowing (typeof, instanceof, in) first, an assertion last, and treat every as in a code review as a question — "why does the author know better than the compiler here?" Sometimes there's a good answer. Often there's a missing type.

@types Packages and DefinitelyTyped

Many npm packages are written in plain JavaScript, so they ship no types. The community maintains type definitions for thousands of them in a giant repository called DefinitelyTyped, published under the @types scope:

npm install express # the actual library (no types)
npm install --save-dev @types/express # its type definitions

npm install --save-dev @types/node # types for Node built-ins (fs, path, process)

How to know what a package needs:

  • Types built in (axios, mongoose, date-fns, most modern libraries) — install nothing extra; the package ships its own .d.ts files
  • Types on DefinitelyTyped (express, cors, jsonwebtoken, lodash-es) — install the matching @types/... as a dev dependency (e.g. @types/lodash-es)
  • No types anywhere — rare now; the import errors and you can write a minimal declaration yourself

If an import shows the error "Could not find a declaration file for module X", the fix is almost always npm install --save-dev @types/X.

Recap

  • strict: true always; noUncheckedIndexedAccess is worth the friction
  • Express: type params/body via the Request generics, and remember types are not runtime validation
  • React: props as an interface, useState<T> when inference isn't enough
  • Assertions are a "trust me" — prefer narrowing, audit every as
  • No types in a package? @types/package-name from DefinitelyTyped almost certainly has them

That wraps the TypeScript basics. From here, everything you learned in the JavaScript series applies directly — just with a compiler watching your back. 🚀