How to Actually Use TypeScript (Without Hating It)

2026-03-02 · Nico Brandt

You added TypeScript to a project and spent three hours fighting the compiler. The code worked fine in JavaScript. Now it’s covered in red squiggly lines, and you’re questioning your career choices.

I’ve watched this cycle play out dozens of times. A team adopts TypeScript, cranks strict to true on day one, drowns in errors, and either litters the codebase with any or quietly reverts to .js files. Neither outcome is great.

Here’s the thing: TypeScript is worth it. But how to use TypeScript well looks different from how most tutorials teach it. This guide covers the practical path — the tsconfig settings that matter, the type patterns that pay off, and the ones that waste your time. No dogma. No 400-line generic types. Code that ships.

Start With a tsconfig That Doesn’t Fight You

The tsconfig is where most people go wrong first. TypeScript 6.0 (currently in beta) defaults to strict: true, which is the right destination. It’s a terrible starting point for an existing project.

Here’s a tsconfig for a real project that isn’t starting from scratch:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "strict": false,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Two things to notice. First, strict is false, but noImplicitAny and strictNullChecks are true. Those two flags catch roughly 80% of the bugs that strict mode catches. The remaining flags — strictBindCallApply, strictFunctionTypes, strictPropertyInitialization — are worth enabling later, but they produce a wall of errors in migrating codebases.

Second, skipLibCheck is true. This tells TypeScript to skip type-checking your node_modules. Without it, you’ll get errors from third-party libraries you can’t control. Turn it on. Leave it on.

The Graduated Strict Mode Path

Once your codebase is stable with noImplicitAny and strictNullChecks, add one flag at a time:

  1. strictFunctionTypes — catches subtle bugs where you pass a function with incompatible parameter types
  2. strictBindCallApply — type-checks .bind(), .call(), and .apply()
  3. strictPropertyInitialization — requires class properties to be initialized in the constructor
  4. noUncheckedIndexedAccess — treats array and object index access as possibly undefined

Add each flag, fix the errors it surfaces, commit. Then add the next one. This takes a week or two in a mid-sized codebase, not months.

When you’ve enabled all of them, replace the individual flags with "strict": true. Same behavior, cleaner config.

The goal isn’t strict mode for its own sake. It’s catching bugs before they hit production. Graduated adoption gets you there without the team revolt.

Type What Matters, Skip What Doesn’t

New TypeScript users type everything. Every variable. Every intermediate value. Every callback parameter that the compiler already knows about. This is where the hatred starts.

TypeScript’s inference engine is good. In most cases, it already knows the type. You don’t need to tell it.

// Don't do this
const name: string = "Nico";
const count: number = items.length;
const isActive: boolean = user.status === "active";

// Do this — TypeScript already knows
const name = "Nico";
const count = items.length;
const isActive = user.status === "active";

Type annotations earn their keep at boundaries. Function parameters. Function return types. Object shapes that cross module boundaries. That’s where bugs hide.

// Type the boundaries
function calculateDiscount(
  price: number,
  tier: "basic" | "pro" | "enterprise"
): number {
  const multipliers = { basic: 0, pro: 0.1, enterprise: 0.2 };
  return price * multipliers[tier];
}

The return type annotation on that function is optional — TypeScript can infer it. But adding it catches a class of bugs where you accidentally return undefined from one branch. In practice, I annotate return types on any exported function. Internal helpers, I leave to inference.

Here’s the rule I use: type the edges, trust the middle. If data enters your module, type it. If data leaves your module, type it. Everything between those two points, let inference handle.

When any Is Fine (Yes, Really)

The TypeScript community treats any like a moral failing. In production, it’s a tool.

Use any when:

Don’t use any when:

If you want a middle ground, use unknown instead of any. It says “I don’t know what this is” while still forcing you to narrow the type before using it. In practice, unknown is what any should have been.

// any lets you do anything — no safety net
function processInput(data: any) {
  return data.name.toUpperCase(); // no error, but crashes if data has no name
}

// unknown forces you to check first
function processInput(data: unknown) {
  if (typeof data === "object" && data !== null && "name" in data) {
    return (data as { name: string }).name.toUpperCase();
  }
  throw new Error("Invalid input");
}

More keystrokes. Fewer production incidents. The tradeoff is worth it for code that handles external data — API responses, form inputs, parsed JSON. For internal plumbing where you control both sides, inference and simple annotations are enough.

The Type Patterns That Actually Pay Off

You can go deep into TypeScript’s type system. Conditional types, mapped types, template literal types, recursive generics. Most of it is interesting and irrelevant to shipping software.

Here are the patterns I reach for constantly in production. If you’re looking for a pragmatic comparison of frontend frameworks to pair with TypeScript, that’s worth reading alongside this — the type experience varies significantly between React, Vue, and Svelte.

Discriminated Unions

This is the single most useful advanced pattern in TypeScript. It replaces sprawling if/else chains with exhaustive type checking.

type ApiResponse =
  | { status: "success"; data: User[] }
  | { status: "error"; message: string }
  | { status: "loading" };

function handleResponse(response: ApiResponse) {
  switch (response.status) {
    case "success":
      // TypeScript knows response.data exists here
      return renderUsers(response.data);
    case "error":
      // TypeScript knows response.message exists here
      return showError(response.message);
    case "loading":
      return showSpinner();
  }
}

Add a new status? TypeScript flags every switch statement that doesn’t handle it. In a codebase with dozens of API calls, this prevents an entire category of bugs.

Utility Types You Should Know

TypeScript ships with utility types. Most tutorials list all 20+. You need about five:

// Partial — make all properties optional
// Great for update functions where you only change some fields
function updateUser(id: string, changes: Partial<User>) {
  return { ...getUser(id), ...changes };
}

// Pick — grab specific properties from a type
// Useful for component props that only need part of a larger type
type UserCardProps = Pick<User, "name" | "avatar" | "role">;

// Omit — everything except specific properties
// Common when creating items (ID generated server-side)
type CreateUserInput = Omit<User, "id" | "createdAt">;

// Record — typed key-value maps
// Better than { [key: string]: whatever }
const permissions: Record<UserRole, string[]> = {
  admin: ["read", "write", "delete"],
  editor: ["read", "write"],
  viewer: ["read"],
};

// ReturnType — extract what a function returns
// Useful when a function's return type is complex and you need to reference it
type Config = ReturnType<typeof loadConfig>;

That covers most real-world needs. If you find yourself writing a conditional type with three levels of nesting, step back. There’s probably a simpler design.

Zod for Runtime Validation

TypeScript types disappear at runtime. They’re compile-time only. This means data from outside your application — API responses, form submissions, URL parameters — has no type safety at runtime.

Zod bridges that gap:

import { z } from "zod";

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "editor", "viewer"]),
});

// Infer the TypeScript type from the schema
type User = z.infer<typeof UserSchema>;

// Now validate at runtime
const result = UserSchema.safeParse(apiResponse);
if (result.success) {
  // result.data is fully typed as User
  console.log(result.data.name);
} else {
  console.error(result.error.issues);
}

One schema, both compile-time types and runtime validation. No drift between what your types say and what your code actually checks. If you’re building an API with Go that feeds data to a TypeScript frontend, Zod on the client side ensures the contract holds even when the backend changes.

Migrating an Existing Project (Without Losing Your Mind)

A full rewrite from JavaScript to TypeScript is almost never the right call. Migrate incrementally. Here’s the process I’ve used on three production codebases.

Step 1: Add TypeScript Alongside JavaScript

Install TypeScript and create a tsconfig with allowJs: true:

npm install -D typescript
npx tsc --init

Then edit the tsconfig:

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "strict": false,
    "noImplicitAny": false,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

This changes nothing about your existing code. JavaScript files pass through untouched. You can now rename files from .js to .ts one at a time.

Step 2: Convert Leaf Files First

Start with files that have few or no imports from other files in your project. Utility functions. Constants. Configuration. These are the easiest to type and have the smallest blast radius if something goes wrong.

Rename the file from .js to .ts. Fix whatever errors appear. Most will be implicit any complaints on function parameters. Add the types. Commit.

Don’t touch the core business logic yet. Build confidence with the easy wins first.

Step 3: Turn On Flags Gradually

Once you have 20-30% of your files converted, enable noImplicitAny. Fix the new errors. Then enable strictNullChecks. Fix those.

This is where the real value starts appearing. strictNullChecks alone will surface bugs you didn’t know existed — places where a function can return null but the calling code assumes it won’t.

// strictNullChecks catches this
function findUser(id: string): User | undefined {
  return users.find(u => u.id === id);
}

// This line now errors — findUser might return undefined
const userName = findUser("abc").name;

// Fix: handle the undefined case
const user = findUser("abc");
const userName = user?.name ?? "Unknown";

Every one of those errors is a potential runtime crash you’re preventing. This is where TypeScript earns its keep.

Step 4: Tackle the Core

With the strict flags on and the leaf files converted, work inward. Convert your API layer, your state management, your core business logic. These files benefit the most from types because they’re where data transforms and where bugs are most expensive.

The whole migration typically takes 2-4 weeks for a mid-sized codebase (50-100 files), done alongside regular feature work. No big-bang rewrite. No feature freeze. Each commit makes the codebase slightly safer.

Mistakes That Make You Hate TypeScript

Most TypeScript frustration comes from a few specific patterns. Avoid these and the experience improves dramatically.

Over-Typing Everything

I mentioned this earlier, but it’s worth repeating. If you’re writing types that are longer than the code they describe, something is wrong. TypeScript should reduce your cognitive load, not increase it.

// This is a sign you've gone too far
type DeepPartialReadonlyNullable<T> = {
  readonly [P in keyof T]?: T[P] extends object
    ? DeepPartialReadonlyNullable<T[P]> | null
    : T[P] | null;
};

If you need this type, your data model is probably too complex. Simplify the data first.

Using Enums When Union Types Work

TypeScript enums have footguns. They emit runtime JavaScript, they have numeric reverse mappings that nobody expects, and they don’t work well with type narrowing.

// Avoid
enum Status {
  Active = "active",
  Inactive = "inactive",
  Pending = "pending",
}

// Prefer
type Status = "active" | "inactive" | "pending";

Union types are simpler. They produce no runtime code. They work with discriminated unions. They’re easier to extend. Use them by default; reach for enums only if you need runtime iteration over the values (and even then, consider a const object with as const).

Ignoring Errors Instead of Understanding Them

TypeScript errors are verbose. They’re also usually right. When you see a type error you don’t understand, resist the urge to add as any or @ts-ignore. Read the error from the bottom up — the last line is usually the most specific.

Type '{ name: string; age: number; }' is not assignable to type 'User'.
  Property 'email' is missing in type '{ name: string; age: number; }'
  but required in type 'User'.

Bottom line tells you exactly what’s wrong: you forgot the email property. The error is your pair programmer. Listen to it.

If you genuinely need to suppress an error — maybe a library’s types are wrong — use @ts-expect-error instead of @ts-ignore. The difference: @ts-expect-error will flag itself when the underlying issue is fixed, so you don’t forget to remove the suppression. @ts-ignore silently hides errors forever.

Typing Third-Party Libraries Yourself

If a library doesn’t have types, check DefinitelyTyped first:

npm install -D @types/library-name

If no types exist there either, write a minimal declaration file rather than typing the entire library:

// src/types/some-library.d.ts
declare module "some-library" {
  export function doThing(input: string): Promise<Result>;
  // Only type what you actually use
}

Type what you call. Skip what you don’t. You’re not writing documentation for the library — you’re giving the compiler enough information to help you.

The Tools That Make TypeScript Bearable

The compiler alone isn’t enough. A few tools turn TypeScript from “tolerable” to “I wouldn’t go back.”

Editor setup matters. VS Code with the TypeScript language server gives you inline errors, auto-imports, and rename refactoring that works across files. If your editor isn’t showing type errors as you write, you’re missing half the value.

ts-reset by Matt Pocock fixes annoying default behaviors. JSON.parse returns unknown instead of any. .filter(Boolean) actually narrows the type. Small fixes, large quality-of-life improvement.

typescript-eslint catches patterns the compiler doesn’t — unused variables, floating promises, unsafe any usage. Pair it with the strict-type-checked config for maximum coverage.

tsx replaces ts-node for running TypeScript directly. It’s faster (uses esbuild under the hood) and handles ESM without configuration headaches. For scripts and dev work, it’s the pragmatic choice.

# Instead of compiling then running
npx tsc && node dist/script.js

# Run directly
npx tsx src/script.ts

TypeScript Is a Spectrum, Not a Switch

Here’s what most guides get wrong: they present TypeScript as all-or-nothing. Full strict mode. Every type explicit. No any anywhere. That’s a recipe for frustration.

In practice, TypeScript adoption is a spectrum. You can start with the loosest settings and tighten over time. You can have strict files alongside lenient ones. You can use any in prototype code and replace it when the design solidifies.

The point of learning how to use TypeScript isn’t to satisfy the compiler. It’s to catch bugs earlier, refactor with confidence, and make your codebase navigable six months from now. Every step along that spectrum delivers value — you don’t need to reach the end to benefit.

Start with noImplicitAny and strictNullChecks. Type your function boundaries. Use discriminated unions for state. Migrate one file at a time. That’s it. That’s the whole approach.

The rest is turning the dial as your team gets comfortable. And you will get comfortable — because once TypeScript catches a bug that would have taken you an hour to debug in production, you stop fighting it and start leaning on it.

That’s when it stops feeling like overhead and starts feeling like infrastructure.