The Power of TypeScript's 'in' Operator: Stop Writing Hacky Type Checks

May 28, 2025|
typescript |type-safety |best-practices |type-narrowing |javascript |

TypeScript gives us many tools to write safer code through its type system. One of the most underappreciated features is the in operator for type narrowing[^1]. In this article, I'll show you why the in operator should be your go-to solution when dealing with property checks and discriminated unions, and why many common alternatives lead to brittle, error-prone code.

Important note: The in operator checks for property existence, but does NOT check if the property value is null or undefined. It only confirms the property exists on the object or its prototype chain.

What is the 'in' Operator?

The in operator in JavaScript tests if a property exists on an object or its prototype chain[^2]. TypeScript leverages this operator to help narrow types, which is incredibly useful for handling union types.

Common Scenarios Where 'in' Shines

Scenario 1: Discriminated Unions

Let's look at a common pattern - differentiating between object types in a union[^3]:

type Circle = {
  kind: 'circle';
  radius: number;
};

type Rectangle = {
  kind: 'rectangle';
  width: number;
  height: number;
};

type Shape = Circle | Rectangle;

// The right way using 'in'
function calculateArea(shape: Shape): number {
  if ('radius' in shape) {
    // TypeScript knows shape is Circle here
    return Math.PI * shape.radius * shape.radius;
  } else {
    // TypeScript knows shape is Rectangle here
    return shape.width * shape.height;
  }
}

Scenario 2: Optional Properties

interface Config {
  endpoint?: string;
  timeout?: number;
  retries?: number;
}

function initializeApi(config: Config) {
  // Using 'in' to check if optional properties exist
  if ('endpoint' in config) {
    console.log(`Using custom endpoint: ${config.endpoint}`);
  }
  
  if ('timeout' in config) {
    console.log(`Setting timeout to: ${config.timeout}ms`);
  }
}

The Hacky Alternatives: Why They're Problematic

Now let's look at some common alternatives and why they're problematic:

Anti-pattern 1: Type Assertions

// ❌ Don't do this
function calculateAreaWrong(shape: Shape): number {
  // BAD: Using type assertions
  if ((shape as any).radius !== undefined) {
    return Math.PI * (shape as Circle).radius * (shape as Circle).radius;
  } else {
    return (shape as Rectangle).width * (shape as Rectangle).height;
  }
}

Problems:

  • Bypasses TypeScript's type checking with as any
  • Requires multiple type assertions
  • Doesn't automatically narrow the type
  • Can lead to runtime errors if shape structure changes

Anti-pattern 2: Property Access with Undefined Checks

// ❌ Avoid this pattern
function processUser(user: { name: string; admin?: boolean }) {
  // BAD: Using property access + undefined check
  if (user.admin !== undefined) {
    // This works, but doesn't distinguish between 
    // `admin: undefined` and property not present
    console.log("User is an admin");
  }
}

Problems:

  • Doesn't distinguish between property not existing and property set to undefined
  • Verbose when checking multiple properties
  • Requires knowledge of what the property values might be

Anti-pattern 3: hasOwnProperty

// ❌ Outdated approach
function hasAdminAccess(user: any): boolean {
  // BAD: Using hasOwnProperty
  if (Object.prototype.hasOwnProperty.call(user, 'isAdmin')) {
    return user.isAdmin;
  }
  return false;
}

Problems:

  • Doesn't check the prototype chain
  • Doesn't narrow types for TypeScript
  • Verbose compared to the in operator
  • Type safety is lost with any

Anti-pattern 4: Try/Catch Property Access

// ❌ Never do this
function getConfigValue(config: any, key: string): any {
  // BAD: Using try/catch to check properties
  try {
    const value = config[key];
    return value;
  } catch {
    return null;
  }
}

Problems:

  • Extremely inefficient
  • Catches unrelated errors too
  • Abuses exception handling for control flow
  • No type narrowing
  • Returns any

Why 'in' Is Superior

The in operator provides several key advantages:

  1. Built-in Type Narrowing: The compiler understands and narrows types automatically[^4]
  2. Checks Entire Prototype Chain: Unlike hasOwnProperty
  3. Concise Syntax: Clean, readable code
  4. Runtime Safety: No need for type assertions
  5. Handles Discriminated Unions: Perfect for pattern matching on different object shapes

The Null/Undefined Gotcha

One important limitation to be aware of: the in operator only checks for property existence, not property value. A property can exist but still have a null or undefined value:

const user = {
  name: "Alice",
  role: undefined
};

// This will be true! 'role' exists as a property, even though its value is undefined
console.log('role' in user); // true

// If you need to check both existence AND non-null/undefined value:
if ('role' in user && user.role !== undefined && user.role !== null) {
  // Now we know the property exists AND has a meaningful value
  console.log(`User role is: ${user.role}`);
}

This is a common source of confusion. If you need to check both property existence and that the value is not null or undefined, you'll need an additional check after using the in operator.

Real-world Example: API Response Handling

Let's look at a more complex example handling API responses:

// ✅ Clean API response handling
type SuccessResponse = {
  status: 'success';
  data: {
    items: string[];
    count: number;
  };
};

type ErrorResponse = {
  status: 'error';
  error: {
    code: number;
    message: string;
  };
};

type LoadingState = {
  status: 'loading';
};

type ApiResponse = SuccessResponse | ErrorResponse | LoadingState;

function handleApiResponse(response: ApiResponse) {
  // Using 'in' for clean type narrowing
  if ('error' in response) {
    // TypeScript knows this is ErrorResponse
    console.error(`Error ${response.error.code}: ${response.error.message}`);
    return null;
  } 
  
  if ('data' in response) {
    // TypeScript knows this is SuccessResponse
    return response.data.items;
  }
  
  // TypeScript knows this is LoadingState
  console.log('Loading data...');
  return [];
}

The same function with hacky alternatives would be much more verbose and error-prone:

// ❌ Verbose alternative
function handleApiResponseHacky(response: ApiResponse) {
  // BAD: Using property access and type assertions
  if (response.status === 'error') {
    // Still need to cast to access properties safely
    const errorRes = response as ErrorResponse;
    console.error(`Error ${errorRes.error.code}: ${errorRes.error.message}`);
    return null;
  } 
  
  if (response.status === 'success') {
    // Still need to cast
    const successRes = response as SuccessResponse;
    return successRes.data.items;
  }
  
  // Assuming it's loading state
  console.log('Loading data...');
  return [];
}

In this case, using response.status is actually fine since it exists in all union members, but notice we still need type assertions to access the specific properties safely.

Best Practices Summary

When to use the in operator:

  • Checking for property existence in union types
  • Working with discriminated unions
  • Handling optional properties
  • Type narrowing without type assertions

When to combine with additional checks:

  • When you need to ensure a property value is not null or undefined
  • When dealing with APIs that might return properties with null values

Conclusion

The in operator in TypeScript is a powerful tool for type narrowing that leads to cleaner, safer, and more maintainable code. By embracing it instead of resorting to hacky alternatives, you'll:

  • Write more concise code
  • Let TypeScript do more work for you
  • Catch more errors at compile time
  • Create more maintainable codebases

Just remember its limitation regarding null and undefined values – the in operator checks for property existence, not property value state. For complete safety, sometimes you'll need both the in check and a separate null/undefined check.

While there might be rare cases where alternative approaches are necessary, the in operator should be your default choice when checking for property existence and narrowing types in TypeScript.

Next time you find yourself reaching for type assertions or elaborate property checks, remember that the simple in operator might be all you need.

Happy coding! 🚀


References

[^1]: TypeScript Handbook - Narrowing [^2]: MDN - in operator [^3]: TypeScript Handbook - Discriminated Unions [^4]: TypeScript Handbook - Type Predicates