Common TypeScript Gotchas

Difficulty: Advanced

TypeScript has several behaviors that surprise even experienced developers. Understanding these gotchas is essential for interviews and for writing correct TypeScript code. The three most important gotchas are structural typing surprises, excess property checks, and type widening - each represents a case where TypeScript's behavior differs from what most developers expect.

Structural typing means TypeScript checks type compatibility based on shape, not identity. If an object has all the required properties of a type, it is compatible regardless of where it was defined. This means a class instance can be assigned to an interface it does not explicitly implement, and two unrelated interfaces with the same shape are interchangeable. While this is powerful, it can lead to bugs where extra properties sneak through function boundaries undetected.

Excess property checks are a special stricter check that TypeScript applies only to object literals assigned directly to a typed variable or passed as a function argument. When you write `const point: Point = { x: 1, y: 2, z: 3 }`, TypeScript flags `z` as an excess property. However, if you first assign the object to a variable and then pass that variable, the excess property check does not apply - TypeScript only checks structural compatibility. This inconsistency is intentional: direct object literals are likely typos, while pre-existing objects may legitimately have extra properties.

Type widening is TypeScript's default behavior of inferring the widest possible type for mutable variables. When you write `let x = 'hello'`, TypeScript infers `string`, not the literal type `'hello'`. This is because `let` variables can be reassigned. Using `const` or `as const` preserves the narrow literal type. This distinction matters when passing values to functions that expect specific string literal types - a `let` variable of type `string` is not assignable to a parameter of type `'hello' | 'world'`.

Code examples

Structural Typing Surprises

interface Point2D {
  x: number;
  y: number;
}

interface Coordinate {
  x: number;
  y: number;
}

// Different interfaces, same shape - fully compatible
const point: Point2D = { x: 1, y: 2 };
const coord: Coordinate = point; // No error!
console.log(`coord: (${coord.x}, ${coord.y})`);

// Extra properties are allowed via structural typing
const point3D = { x: 1, y: 2, z: 3 };
const flat: Point2D = point3D; // OK - has x and y
console.log(`flat: (${flat.x}, ${flat.y})`);

// Accessing z through flat is a type error, but the data is there
console.log(`z exists: ${(flat as any).z}`);

// Function parameters work the same way
function distance(p: Point2D): number {
  return Math.sqrt(p.x * p.x + p.y * p.y);
}

console.log(`Distance: ${distance(point3D).toFixed(2)}`);

Structural typing allows point3D (with extra z) to be assigned to Point2D because it has all required properties. The extra z property is preserved at runtime but invisible to the type system through the Point2D reference.

Excess Property Checks

interface Config {
  host: string;
  port: number;
}

// DIRECT object literal - excess property check applies
// const config: Config = { host: 'localhost', port: 3000, debug: true };
// Error: 'debug' does not exist in type 'Config'

// INDIRECT assignment - no excess property check
const options = { host: 'localhost', port: 3000, debug: true };
const config: Config = options; // No error!

console.log(`Host: ${config.host}`);
console.log(`Port: ${config.port}`);
console.log(`Has debug: ${'debug' in options}`);

// Function parameter - same behavior
function createServer(config: Config): string {
  return `Server at ${config.host}:${config.port}`;
}

// Direct literal would error:
// createServer({ host: 'localhost', port: 3000, debug: true }); // Error

// Via variable works:
const serverConfig = { host: 'localhost', port: 8080, debug: false };
console.log(createServer(serverConfig));

Excess property checks only apply to fresh object literals. When assigning a pre-existing variable, TypeScript only checks structural compatibility, allowing extra properties through.

Type Widening and Narrowing

// Type widening with let vs const
let mutableStr = 'hello'; // type: string (widened)
const immutableStr = 'hello'; // type: 'hello' (literal)

console.log(`mutable type: string (widened)`);
console.log(`immutable type: 'hello' (literal)`);

// as const prevents widening
const config = {
  mode: 'production',
  port: 3000
} as const;
// type: { readonly mode: 'production'; readonly port: 3000 }

console.log(`mode: ${config.mode}`);
console.log(`port: ${config.port}`);

// Practical impact: function with literal parameter
function setMode(mode: 'development' | 'production'): string {
  return `Mode set to: ${mode}`;
}

// let mode = 'production'; // type: string - ERROR: not assignable
const mode = 'production'; // type: 'production' - OK
console.log(setMode(mode));

// Array widening
let arr = [1, 'two', true]; // type: (number | string | boolean)[]
const constArr = [1, 'two', true] as const; // type: readonly [1, 'two', true]
console.log(`Array length: ${arr.length}`);
console.log(`Const tuple length: ${constArr.length}`);

let variables are widened to their base type. const variables and 'as const' preserve literal types. This matters when passing values to functions expecting literal union types.

Key points

Concepts covered

structural typing, excess property checks, type widening, literal types, freshness, type compatibility, covariance