TypeScript Tips for Larger Codebases

TypeScript pays off most on large codebases, but only if you use it deliberately. A project littered with any and type assertions is still JavaScript underneath, just with an extra build step.

These are the habits that make a real difference.

Turn on strict from day one

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "exactOptionalPropertyTypes": true
  }
}

Turning on strict in a new project is easy. Turning it on in a project that has been running for two years is a project of its own.

noUncheckedIndexedAccess is the most interesting one in that list. It makes array access return T | undefined, which is what actually happens:

const users: User[] = [];
const first = users[0];   // User | undefined, not User
console.log(first.name);  // Compile error, exactly as it should be

Without the flag, TypeScript happily says users[0] is a User even when the array is empty, and you find out at runtime instead of at build time.

Avoid any, use unknown

any switches off all type checking and it spreads: an any value flowing through several layers of functions disables checking everywhere it goes.

When you genuinely do not know the type yet, unknown is the right choice:

async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const data: unknown = await res.json();

  // You are forced to check before using it
  return userSchema.parse(data);
}

unknown lets you do nothing with the value until you narrow it. That is exactly what you want for data coming from outside.

Validate at the system boundary

This is the point I consider most important. TypeScript only exists at compile time. When data comes from an API, a database, environment variables or localStorage, nothing guarantees it matches the type you declared.

Writing const user = await res.json() as User is lying to yourself.

The right approach is validating at the boundary with something like Zod:

import { z } from "zod";

const userSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  role: z.enum(["admin", "editor", "viewer"]),
  createdAt: z.coerce.date(),
});

type User = z.infer<typeof userSchema>;

z.infer generates the TypeScript type from the schema itself, so you declare it once and never worry about the type drifting away from the validation logic.

Astro Content Collections use exactly this approach for Markdown frontmatter, and it is one of the reasons I like it.

Model your domain with discriminated unions

This is the most powerful tool TypeScript gives you, and also the most underused.

Instead of an object full of optional fields:

// This shape allows meaningless states to exist
interface RequestState {
  loading?: boolean;
  data?: User;
  error?: string;
}

Describe the states that can actually happen:

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: string };

The difference: the first type allows a state where loading: true and there is also an error, which makes no sense. The second makes that state impossible to represent.

And TypeScript narrows automatically on the discriminant:

function render(state: RequestState) {
  switch (state.status) {
    case "loading":
      return <Spinner />;
    case "success":
      return <Profile user={state.data} />;  // data definitely exists
    case "error":
      return <Alert message={state.error} />;
    case "idle":
      return null;
  }
}

The general rule: make invalid states unrepresentable. When the type does not allow a situation to happen, you do not need a test for it.

Force exhaustive handling

Combine a union with a small helper so you never forget a case:

function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

function render(state: RequestState) {
  switch (state.status) {
    case "idle":    return null;
    case "loading": return <Spinner />;
    case "success": return <Profile user={state.data} />;
    default:        return assertNever(state);
  }
}

The code above does not compile because the error case is missing. Tomorrow, when someone adds a new state to the union, the compiler will point at every place that needs updating. This is how you turn the compiler into a to-do list.

Result instead of throw

In most business flows, a Result type is clearer than throwing and catching:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function findUser(id: string): Promise<Result<User, "not_found" | "db_error">> {
  // ...
}

The reason: the signature tells you the function can fail and how it can fail. With throw, that information is not in the type, and the caller has to read the source or wait until runtime to find out.

I do not use this for everything. Programmer errors like an invalid argument should still throw. But expected failures, like a record not found or a validation that did not pass, belong in the return type.

Keep types close to the data

Put type definitions next to the module that owns the data. Project-wide types.ts files always rot over time: they grow, they collect types nobody uses any more, and they create circular dependencies.

features/
  booking/
    types.ts        <- booking related types
    api.ts
    components/
  payment/
    types.ts        <- payment related types
    api.ts

There should be exactly one shared type file for things that really are global, and it should be very small.

A few small but useful tricks

Use satisfies instead of a type annotation when you want checking without losing the narrow inferred type:

const routes = {
  home: "/",
  blog: "/blog",
} satisfies Record<string, string>;

// routes.home has type "/" rather than string

as const for fixed data:

const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number];  // "admin" | "editor" | "viewer"

The list and the type stay in sync because the type is derived from the list.

Do not overdo generics. A function with four type parameters and nested constraints is usually a sign you are solving the wrong problem. Readable types beat clever types, exactly like readable code beats clever code.

Small consistent habits add up to a codebase you still dare to change six months later.