NC Logo UseToolSuite
Developer Tools

TypeScript Type Checking: Common Mistakes and How to Fix Them

Practical guide to TypeScript type checking pitfalls. Covers type narrowing, union types, generics, assertion traps, and runtime validation patterns every developer should know.

Necmeddin Cunedioglu Necmeddin Cunedioglu

Practice what you learn

JSON Formatter & Validator

Try it free →

I spent an entire afternoon last month debugging a TypeScript project that compiled without a single error — and crashed immediately at runtime. The issue? A JSON API response that returned null where my types promised a string. TypeScript had no way of knowing because I’d used as to force the type, and the compiler trusted me blindly.

TypeScript’s type system is powerful, but it only works if you work with it instead of around it. Here are the patterns and pitfalls I’ve collected from years of writing TypeScript in production.

Type Narrowing: The Most Useful Skill Nobody Teaches

Type narrowing is how you take a broad type and make it specific within a code block. This is the single most important TypeScript concept for writing safe code.

typeof Guards

function processValue(input: string | number) {
  if (typeof input === 'string') {
    // TypeScript knows input is string here
    return input.toUpperCase();
  }
  // TypeScript knows input is number here
  return input.toFixed(2);
}

in Operator for Object Types

interface Dog { bark(): void; breed: string; }
interface Cat { meow(): void; color: string; }

function handlePet(pet: Dog | Cat) {
  if ('bark' in pet) {
    pet.bark(); // TypeScript knows this is Dog
  } else {
    pet.meow(); // TypeScript knows this is Cat
  }
}

Discriminated Unions (The Pattern I Use Everywhere)

This is my go-to pattern for handling different response shapes:

type ApiResponse =
  | { status: 'success'; data: User; }
  | { status: 'error'; message: string; code: number; }
  | { status: 'loading'; };

function handleResponse(response: ApiResponse) {
  switch (response.status) {
    case 'success':
      console.log(response.data.name); // ✅ TypeScript knows data exists
      break;
    case 'error':
      console.error(response.message); // ✅ TypeScript knows message exists
      break;
    case 'loading':
      showSpinner();
      break;
  }
}

The status field acts as a discriminant — TypeScript uses it to narrow the type automatically. I use this pattern for form states, API calls, WebSocket messages, and basically anything with multiple possible shapes.

The as Keyword: TypeScript’s Escape Hatch (and Trap)

Type assertions with as are the most common source of runtime errors in TypeScript codebases. They tell the compiler “trust me, I know better” — and the compiler does, without any runtime check.

// Dangerous: no runtime validation
const user = JSON.parse(apiResponse) as User;
// If apiResponse doesn't match User shape, this silently breaks

// Even more dangerous: double assertion
const value = someUnknown as unknown as SpecificType;
// This bypasses ALL type checking

When as Is Acceptable

There are exactly three situations where I think as is justified:

  1. DOM element selection — when you know the element type exists:
const canvas = document.getElementById('game') as HTMLCanvasElement;
  1. Test mocking — creating partial objects for unit tests:
const mockUser = { id: 1, name: 'Test' } as User;
  1. Const assertions — narrowing literal values:
const config = { env: 'production', debug: false } as const;

Everything else deserves runtime validation.

Runtime Validation: Bridging the Compile-Time Gap

TypeScript types disappear at runtime. When data crosses a trust boundary — API responses, user input, file reads, URL parameters — you need runtime validation.

Manual Type Guards

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

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'name' in value &&
    'email' in value &&
    typeof (value as User).id === 'number' &&
    typeof (value as User).name === 'string' &&
    typeof (value as User).email === 'string'
  );
}

// Safe usage
const data = JSON.parse(response);
if (isUser(data)) {
  console.log(data.name); // TypeScript knows this is User
} else {
  throw new Error('Invalid user data');
}

Validation Libraries

For production code, manual type guards become unwieldy. Libraries like Zod combine validation and type inference:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'guest']),
});

type User = z.infer<typeof UserSchema>; // Type generated from schema

const result = UserSchema.safeParse(apiResponse);
if (result.success) {
  console.log(result.data.name); // Fully typed and validated
} else {
  console.error(result.error.issues); // Structured validation errors
}

This eliminates the gap between compile-time types and runtime data entirely. I now use Zod for every external data boundary in my projects.

Working with JSON? Our JSON Formatter helps you inspect and validate JSON structure before writing your TypeScript types. And when converting between config formats, the YAML to JSON Converter handles the format translation.

Generics: Powerful but Easy to Overcomplicate

Generics let you write reusable, type-safe functions and classes. The mistake I see most often is developers making everything generic when it doesn’t need to be.

Good Use of Generics

// A generic function that preserves type information
function getFirst<T>(items: T[]): T | undefined {
  return items[0];
}

const num = getFirst([1, 2, 3]);      // type: number | undefined
const str = getFirst(['a', 'b']);     // type: string | undefined

Constrained Generics

When your generic type needs specific properties:

interface HasId {
  id: string | number;
}

function findById<T extends HasId>(items: T[], id: T['id']): T | undefined {
  return items.find(item => item.id === id);
}

When NOT to Use Generics

// Unnecessary: just use the concrete type
function badGeneric<T extends string>(name: T): T {
  return name;
}
// This generic adds complexity without flexibility

// Better: just use string
function getName(name: string): string {
  return name;
}

My rule of thumb: If a generic type parameter is used in only one position, you probably don’t need it. Generics shine when they connect the types of multiple parameters or a parameter to a return type.

Utility Types You Should Know

TypeScript ships with built-in utility types that solve everyday problems:

interface User {
  id: number;
  name: string;
  email: string;
  createdAt: Date;
}

// Make all properties optional (for patch/update endpoints)
type UpdateUserDTO = Partial<User>;

// Make specific properties required
type CreateUserDTO = Required<Pick<User, 'name' | 'email'>>;

// Exclude specific properties
type PublicUser = Omit<User, 'email'>;

// Make all properties readonly (for immutable state)
type FrozenUser = Readonly<User>;

// Record type for dictionaries
type UserCache = Record<string, User>;

Combining Utility Types

Real-world DTOs often need a combination:

// An update DTO that allows partial changes but never touches id or createdAt
type UpdateUser = Partial<Omit<User, 'id' | 'createdAt'>>;

// A creation DTO that requires name and email but makes everything else optional
type CreateUser = Required<Pick<User, 'name' | 'email'>> & Partial<Omit<User, 'name' | 'email' | 'id'>>;

Common Mistakes I Keep Seeing

Mistake 1: Using any Instead of unknown

// Bad: any disables ALL type checking downstream
function parseConfig(raw: any) {
  return raw.database.host; // No errors, but might crash
}

// Good: unknown forces you to validate before use
function parseConfig(raw: unknown) {
  if (typeof raw === 'object' && raw !== null && 'database' in raw) {
    // Now you need to validate further — TypeScript enforces safety
  }
}

Every any in your codebase is a potential runtime crash. I treat any like a code smell — it’s sometimes necessary (third-party libraries with bad types), but it should always have a comment explaining why.

Mistake 2: Ignoring strictNullChecks

// With strictNullChecks disabled (bad):
const user = users.find(u => u.id === 1);
console.log(user.name); // Might crash at runtime — find can return undefined

// With strictNullChecks enabled (good):
const user = users.find(u => u.id === 1);
if (user) {
  console.log(user.name); // Safe — TypeScript forces the check
}

Always enable strict: true in tsconfig.json. It catches entire categories of bugs that would otherwise only surface in production.

Mistake 3: Enums When Union Types Are Simpler

// Verbose enum
enum Status {
  Active = 'active',
  Inactive = 'inactive',
  Pending = 'pending',
}

// Simpler union type — same type safety, less code
type Status = 'active' | 'inactive' | 'pending';

String literal unions are zero-runtime-cost (they disappear after compilation), autocomplete just as well, and don’t produce the surprising JavaScript output that enums do. I only use enums when I need runtime access to the values (e.g., iterating over all statuses).

tsconfig.json Settings That Matter

The compiler options that have the biggest impact on type safety:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true
  }
}

noUncheckedIndexedAccess is the one most people miss — it makes array access and object index access return T | undefined instead of T, catching a huge category of out-of-bounds bugs.

Quick Reference

PatternWhen to Use
Type narrowing (typeof, in)Handling union types safely
Discriminated unionsAPI responses, events, state machines
Type guards (is)Validating external data
Zod / io-tsProduction API boundaries
GenericsReusable utilities connecting multiple types
Utility types (Partial, Omit)DTOs, API contracts
unknown over anyAny external or untyped data

Further Reading


Working with typed data? Use our JSON Formatter to inspect API responses, the Diff Checker to compare type definitions, and the Regex Tester to validate string patterns in your schemas.

Necmeddin Cunedioglu
Necmeddin Cunedioglu Author

Software developer and the creator of UseToolSuite. I write about the tools and techniques I use daily as a developer — practical guides based on real experience, not theory.