UseToolSuite UseToolSuite

TypeScript Type Checking: Common Mistakes and How to Fix Them

A practical guide to TypeScript's common type-checking mistakes: discriminated unions, exhaustive checks with the 'never' type, Zod runtime validation, generics, and strict tsconfig rules.

Necmeddin Cunedioglu Necmeddin Cunedioglu 8 min read
Part of the Developer Generators: The Tools That Save You Hours Every Week series

Practice what you learn

JSON Formatter & Validator

Try it free →

TypeScript Type Checking: Common Mistakes and How to Fix Them

Here’s a bug that’s easy to ship: a React app compiles with zero TypeScript errors, then crashes the moment it renders. The cause is usually a gap between compile time and runtime — an external API returns null in a field your interface declared as string, and the compiler never caught it because someone used as to force the type assertion: “Trust me, I know what this data is.” The compiler trusts you, and the app crashes.

TypeScript’s type system is powerful, but it rests on one fact that’s easy to forget: types are erased at compile time. Every interface, type alias, and generic constraint disappears during the build. If your compile-time promises don’t match runtime reality, your types aren’t just useless — they’re actively misleading.

This guide covers the most dangerous TypeScript pitfalls: discriminated unions, exhaustive checks with the never type, the cost of any, and how to use Zod to validate data at runtime boundaries.

Stop blindly guessing API architectures. Whenever you integrate a new endpoint, dump the raw response into our JSON Formatter, inspect the data structure visually, and rigorously define your TypeScript boundaries before writing a single line of React.

1. Type Narrowing: The Mathematical Foundation of TypeScript

Type narrowing is the process of taking a broad type (like string | number) and proving to the compiler that the value is a more specific type within a block of code. It’s the core technique for writing safe, predictable TypeScript.

A. The typeof Guard

The simplest narrowing mechanism leverages the native JavaScript typeof operator.

function processFinancialValue(input: string | number) {
  if (typeof input === 'string') {
    // The compiler guarantees 'input' is a string here,
    // so we can safely call string methods.
    return parseFloat(input.replace('$', ''));
  }
  
  // By process of elimination, the compiler knows 'input' MUST be a number here.
  return input.toFixed(2);
}

B. The in Operator for Complex Object Interfaces

When dealing with complex interfaces rather than primitives, you must check for the explicit existence of specific property keys.

interface PostgreSQLDatabase { query(sql: string): void; port: number; }
interface MongoDatabase { aggregate(pipeline: any[]): void; uri: string; }

function executeDatabaseOperation(db: PostgreSQLDatabase | MongoDatabase) {
  if ('query' in db) {
    db.query('SELECT * FROM users;'); // The compiler knows this is PostgreSQL
  } else {
    db.aggregate([{ $match: { active: true } }]); // The compiler knows this is Mongo
  }
}

2. Advanced Architecture: Discriminated Unions

The in operator gets unwieldy when a union contains four or five interfaces. The standard pattern for managing disparate object types is the discriminated union (also called a tagged union).

It requires a literal string “discriminant” field on every interface in the union.

// 1. Define the strictly tagged interfaces
type ApiSuccess = { status: 'success'; data: UserPayload; timestamp: number; };
type ApiError   = { status: 'error'; error_code: number; message: string; };
type ApiLoading = { status: 'loading'; progress_percentage: number; };

// 2. Create the Discriminated Union
type ApiResponse = ApiSuccess | ApiError | ApiLoading;

function renderDashboard(response: ApiResponse) {
  // 3. Narrow the type using a switch statement against the discriminant field
  switch (response.status) {
    case 'success':
      // The compiler grants full intellisense access to response.data
      console.log(`Loaded User: ${response.data.username}`); 
      break;
    case 'error':
      // The compiler grants access to response.error_code
      console.error(`Failure [${response.error_code}]: ${response.message}`);
      break;
    case 'loading':
      renderSpinner(response.progress_percentage);
      break;
  }
}

The Power of Exhaustiveness Checking (The never Type)

What happens if someone adds a fourth state (type ApiTimeout = { status: 'timeout' }) but forgets to update the renderDashboard switch? The code compiles, but the timeout state silently fails to render.

You can force the compiler to flag this with an exhaustiveness check using the never type.

function renderDashboard(response: ApiResponse) {
  switch (response.status) {
    case 'success': /* ... */ break;
    case 'error': /* ... */ break;
    case 'loading': /* ... */ break;
    default:
      // If a developer adds 'timeout' to the union but doesn't add a case block,
      // the 'response' object will fall through to the default block.
      // TypeScript will attempt to assign the 'timeout' object to the 'never' type,
      // which triggers an immediate, hard Compile-Time Error.
      const _exhaustiveCheck: never = response;
      throw new Error(`Unhandled state: ${response}`);
  }
}

This pattern prevents a whole class of runtime bugs in Redux reducers and state machines.

3. The as Keyword: The Escape Hatch

Type assertions with the as keyword are one of the most dangerous things in a TypeScript codebase. They tell the compiler: “Stop checking my work. Trust me.”

// ❌ Zero runtime validation
const rawPayload = await fetch('/api/user').then(res => res.json());
const user = rawPayload as UserProfile; 
// If the backend drops a field or returns null where a string was expected,
// 'user' is corrupted and the app crashes during rendering.

The only three legitimate uses for as

There are three specific cases where as is justified:

  1. DOM element selection: the compiler can’t read your HTML to know what elements exist.
const canvas = document.getElementById('webgl-renderer') as HTMLCanvasElement;
  1. Mocking in tests: building partial fake objects for Jest injection.
const mockAuthToken = { id: 999, scope: 'admin' } as AuthToken;
  1. Const assertions: narrowing literal objects to readonly structures instead of broad primitives.
const HTTP_METHODS = { GET: 'GET', POST: 'POST' } as const;
// The type is locked to { readonly GET: "GET"; readonly POST: "POST" }

Every other case needs runtime validation.

4. Runtime Validation: Bridging the Compile-Time Gap (Zod)

Because TypeScript types are erased at compile time, you have no guarantees about the shape of data crossing your network boundaries. When parsing external JSON APIs, reading files, or accepting user input, you need a runtime validation boundary.

The standard: Zod schema validation

Writing manual isUser() type-guard functions is tedious and doesn’t scale. The common choice for boundary validation is Zod (or libraries like Joi or Yup).

You define a runtime schema, and Zod infers the TypeScript type from that schema, so there’s no duplication.

import { z } from 'zod';

// 1. Define the runtime validation boundary
const UserProfileSchema = z.object({
  user_id: z.string().uuid(),
  username: z.string().min(3).max(32),
  email: z.string().email(),
  // Strict enum validation
  role: z.enum(['admin', 'editor', 'viewer']),
  // Optional field mapping
  last_login: z.string().datetime().optional(),
});

// 2. Infer the TypeScript type from the schema
type UserProfile = z.infer<typeof UserProfileSchema>; 

async function fetchUser() {
  const rawJson = await fetch('/api/user/123').then(res => res.json());
  
  // 3. Validate at runtime
  const validationResult = UserProfileSchema.safeParse(rawJson);
  
  if (validationResult.success) {
    // The compiler guarantees validationResult.data matches UserProfile.
    console.log(validationResult.data.email); 
  } else {
    // Zod returns a structured array of field failures for debugging
    console.error("Payload validation failed:", validationResult.error.issues);
  }
}

If the backend ships a breaking change that returns an invalid UUID, Zod catches it, fails the parse safely, and logs the exact error — preventing a render crash.

5. Advanced Generics Architecture

Generics let you write reusable, type-safe utility functions. But they’re easy to overuse, which produces unreadable, overly complex types.

Constraints with extends

When building a generic function, you often need to guarantee the incoming object has a specific property. You enforce that with the extends constraint.

interface DatabaseRecord {
  uuid: string;
}

// The constraint <T extends DatabaseRecord> makes the compiler
// reject any object without a 'uuid' string property.
function indexRecord<T extends DatabaseRecord>(records: T[], targetId: string): T | undefined {
  return records.find(record => record.uuid === targetId);
}

The “Over-Engineering” Trap

If a generic type parameter (<T>) is utilized in exactly one position within a function signature, you do not need a generic. You are introducing unnecessary compilation overhead.

// ❌ OVER-ENGINEERED AND POINTLESS
function logMessage<T extends string>(message: T): void {
  console.log(message);
}

// ✅ CLEAN ARCHITECTURE
function logMessage(message: string): void {
  console.log(message);
}

The rule: generics exist to link the type of an input parameter to the return type, or to link two parameters’ types together.

6. The any vs unknown Catastrophe

The any keyword is a virus. If you apply any to a variable, you are entirely turning off the TypeScript compiler for that specific variable and every single downstream function that touches it.

// ❌ The virus spreads
function processPayload(rawPayload: any) {
  // The compiler allows this even if nested_data doesn't exist,
  // which throws "Cannot read properties of undefined" at runtime.
  return rawPayload.database.nested_data.execute(); 
}

The solution: unknown

If you genuinely don’t know the shape of incoming data (parsing a third-party webhook, say), use the unknown type.

unknown represents “anything,” but unlike any, the compiler won’t let you touch the variable until you narrow the type explicitly.

// ✅ SECURE ARCHITECTURE
function processWebhook(rawPayload: unknown) {
  // The compiler screams: "Object is of type 'unknown'."
  // rawPayload.database.execute(); 
  
  // You MUST prove the structure to the compiler first:
  if (
    typeof rawPayload === 'object' && 
    rawPayload !== null && 
    'event_type' in rawPayload
  ) {
    // The compiler now allows access
    console.log("Processing event:", rawPayload.event_type);
  }
}

7. Mandatory tsconfig.json Compiler Strictness

By default, TypeScript is permissive so legacy JavaScript can migrate easily. For new projects, turn on maximum strictness.

Make sure your tsconfig.json has these flags:

{
  "compilerOptions": {
    /* Enables ALL strict type checking options (strictNullChecks, noImplicitAny, etc) */
    "strict": true,
    
    /* The most critical safety flag. If you access array[5], it forces the return type to 
       be (Type | undefined), preventing out-of-bounds array crashes. */
    "noUncheckedIndexedAccess": true,
    
    /* Prevents developers from assigning undefined to optional properties that 
       should strictly be completely omitted from the object. */
    "exactOptionalPropertyTypes": true,
    
    /* Forces every code path in a function to return a value. */
    "noImplicitReturns": true,
    
    /* Prevents bugs in switch statements where a developer forgets the 'break' keyword. */
    "noFallthroughCasesInSwitch": true
  }
}

Further Reading


Don’t rely on your IDE alone to catch these issues. Use our JSON Formatter to inspect API response payloads, and our JSON to TypeScript Converter to generate interfaces directly from raw API output instead of writing them by hand.

Necmeddin Cunedioglu
Necmeddin Cunedioglu Author
8 min read
-- views

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.