TS

Common Mistakes

TypeScript · 7 entries

Overusing any

syntax
// BAD: any everywhere
function process(data: any): any { ... }
// GOOD: use proper types
function process(data: unknown): Result { ... }
example
// BAD — any silently disables all type safety
function parseConfig(raw: any) {
  return raw.database.host; // No error even if raw is null
}

// GOOD — unknown forces runtime checks
function parseConfig(raw: unknown): { host: string; port: number } {
  if (
    typeof raw === "object" && raw !== null &&
    "database" in raw &&
    typeof (raw as any).database?.host === "string"
  ) {
    const db = (raw as { database: { host: string; port: number } }).database;
    return db;
  }
  throw new Error("Invalid config shape");
}

// BEST — use a validation library
// const config = ConfigSchema.parse(raw);
output
// any: zero safety. unknown: safe. Validated schema: safest.

Note any is infectious — any value that touches any also becomes any, spreading unsafety silently through your codebase. Use eslint rule @typescript-eslint/no-explicit-any to catch it. Legitimate uses: interfacing with very dynamic libraries, or as a temporary measure during migration.

Unnecessary / Incorrect Generics

syntax
// BAD: generic that adds no value
function bad<T extends string>(s: T): T { return s; }
// GOOD: just use the concrete type
function good(s: string): string { return s; }
example
// BAD — T is unused, just makes the signature confusing
function logValue<T>(value: T): void {
  console.log(value);
}
// GOOD — no generic needed
function logValue(value: unknown): void {
  console.log(value);
}

// BAD — generic is always forced to one type anyway
function fetchUser<T extends User>(id: string): Promise<T> { ... }
// GOOD — just return the concrete type
function fetchUser(id: string): Promise<User> { ... }

// GOOD use of generics — T connects input to output
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map(item => item[key]);
}
output
// Use generics when T appears in at least two positions (input→output link)

Note A generic type parameter is useful when it connects two or more positions: input to output, or one parameter to another. If T appears in only one spot, a concrete type (or unknown) is simpler and clearer. Over-genericizing is a common code smell in TypeScript.

Enum Pitfalls

syntax
// Numeric enum accepts any number
enum Status { Active = 0, Inactive = 1 }
const s: Status = 999; // No error!
example
// PITFALL 1: Numeric enums accept any number
enum Priority { Low, Medium, High }
const p: Priority = 42; // Compiles fine! No type error.

// PITFALL 2: const enums break with isolatedModules
const enum Dir { Up, Down } // Error with isolatedModules

// PITFALL 3: Enums are not tree-shakeable
enum Color { Red, Green, Blue }
// Compiles to a runtime object — bundlers cannot remove unused members

// ALTERNATIVE: Use string literal unions
type Priority = "low" | "medium" | "high";
const p: Priority = "low";
// p = "whatever"; // Error — properly type-safe
output
// String literal unions are simpler, safer, and more compatible

Note Numeric enums have a type safety gap — any number is assignable. String enums are safer but add runtime overhead. const enums are erased but break with Babel/esbuild/SWC. For most cases, string literal unions (optionally with an as const object for runtime values) are the pragmatic choice.

Type Assertion vs Type Guard

syntax
// DANGEROUS: assertion (no runtime check)
const user = data as User;
// SAFE: type guard (runtime check)
if (isUser(data)) { /* data is User */ }
example
interface User {
  id: string;
  name: string;
  email: string;
}

// BAD: assertion — no runtime check, crashes if wrong
const user = JSON.parse(rawJson) as User;
console.log(user.email.toUpperCase()); // Runtime crash if email is missing

// GOOD: type guard — verifies at runtime
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    "email" in value
  );
}

const data = JSON.parse(rawJson);
if (isUser(data)) {
  console.log(data.email.toUpperCase()); // Safe
} else {
  console.error("Invalid user data");
}
output
// Assertions trust you blindly; guards verify at runtime

Note Type assertions (as Type) are a compile-time lie — they produce zero runtime code. Type guards produce actual runtime checks. Always prefer guards for external data (API responses, user input, file reads). Assertions are acceptable for values you genuinely control (e.g., DOM elements you know exist).

Implicit any

syntax
// BAD: no annotation, no inference → implicit any
function process(data) { ... }
// GOOD: annotate parameters
function process(data: string) { ... }
example
// These all cause implicit any errors with noImplicitAny:

// Function parameters without annotations
function greet(name) { // Error: Parameter 'name' implicitly has 'any' type
  return `Hello, ${name}`;
}

// Destructured parameters
function render({ title, body }) { // Error on both
  return `<h1>${title}</h1><p>${body}</p>`;
}

// Fix: always annotate function parameters
function greet(name: string) {
  return `Hello, ${name}`;
}

function render({ title, body }: { title: string; body: string }) {
  return `<h1>${title}</h1><p>${body}</p>`;
}
output
// noImplicitAny (enabled by strict: true) catches unannotated parameters

Note noImplicitAny is part of strict mode and catches function parameters that would silently become any. This is one of the most important checks — without it, many functions quietly lose all type safety. Callback parameters in typed contexts (like .map(), .filter()) are inferred and do not trigger this error.

Trusting readonly at Runtime

syntax
// readonly is compile-time only
const arr: readonly number[] = [1, 2, 3];
(arr as number[]).push(4); // No runtime protection
example
interface Config {
  readonly apiKey: string;
  readonly maxRetries: number;
}

const config: Config = { apiKey: "secret", maxRetries: 3 };

// TypeScript prevents this:
// config.apiKey = "changed"; // Error

// But at runtime, nothing stops this:
(config as any).apiKey = "changed"; // Works at runtime!
console.log(config.apiKey); // "changed"

// For real immutability, use Object.freeze:
const safeConfig = Object.freeze({ apiKey: "secret", maxRetries: 3 });
// safeConfig.apiKey = "x"; // Runtime TypeError + compile error
output
// readonly is erased at runtime — use Object.freeze for true immutability

Note readonly, Readonly<T>, and ReadonlyArray are all compile-time only. They are stripped during compilation and provide zero runtime enforcement. Code that bypasses the type system (any casts, JavaScript interop) can still mutate. For sensitive data, combine readonly with Object.freeze or structuredClone.

Structural Typing Surprises

syntax
// TypeScript uses structural (shape) typing, not nominal (name) typing
example
interface Cat {
  name: string;
  purr(): void;
}

interface Robot {
  name: string;
  purr(): void;
}

// These are the SAME type to TypeScript!
const robot: Robot = { name: "RoboCat", purr() { console.log("bzzz"); } };
const cat: Cat = robot; // No error — shapes match

// Excess property checking only works on object literals:
const direct: Cat = {
  name: "Whiskers",
  purr() {},
  // batteries: true, // Error: Object literal may only specify known properties
};

// But NOT on variables:
const obj = { name: "X", purr() {}, batteries: true };
const sneaky: Cat = obj; // No error — extra properties allowed from variables
output
// Two types with the same shape are interchangeable, regardless of name

Note Structural typing means TypeScript cares about shape, not name. Excess property checking only catches extra properties on direct object literals — not on variables. This often surprises developers from C#/Java backgrounds. Use branded types if you need nominal distinction.