Learn

/

Common Mistakes

Common Mistakes

8 patterns

Overusing any, unnecessary type assertions, ignoring strict flags, and other TypeScript anti-patterns that compile but break. You'll hit this when TypeScript stops catching bugs it should have caught.

Avoid
function processData(data: any) {
  // No errors, but no safety either
  data.forEach((item: any) => {
    console.log(item.naem); // Typo
    updateRecord(item.id.toFixed()); // id might not be number
  });
}

// any spreads: anything derived from
// an any value is also any
function processData(data: any) {
  // No errors, but no safety either
  data.forEach((item: any) => {
    console.log(item.naem); // Typo
    updateRecord(item.id.toFixed()); // id might not be number
  });
}

// any spreads: anything derived from
// an any value is also any

Prefer
interface DataRecord {
  id: number;
  name: string;
  status: "active" | "inactive";
}

function processData(data: DataRecord[]) {
  data.forEach((item) => {
    console.log(item.name); // Autocomplete
    // item.naem; // Error: typo caught
    updateRecord(item.id.toFixed());
  });
}
interface DataRecord {
  id: number;
  name: string;
  status: "active" | "inactive";
}

function processData(data: DataRecord[]) {
  data.forEach((item) => {
    console.log(item.name); // Autocomplete
    // item.naem; // Error: typo caught
    updateRecord(item.id.toFixed());
  });
}
Why avoid

Using any is viral: any value derived from an any expression is also any. A single any at the top of a data pipeline disables type checking for everything that touches that data. Typos, wrong method calls, and missing properties all compile without errors.

Why prefer

Defining a proper interface for your data gives you autocomplete, catches typos, and validates operations at compile time. If you receive untyped data (from an API, for example), validate it at the boundary and type it once. Everything downstream benefits from the types.

TypeScript: Types from Types
Avoid
const input = document.getElementById(
  "search"
) as HTMLInputElement;

// If the element doesn't exist,
// input is null cast to HTMLInputElement
// Accessing .value crashes at runtime
console.log(input.value);

const data = JSON.parse(raw) as User;
// No runtime validation, just a promise
const input = document.getElementById(
  "search"
) as HTMLInputElement;

// If the element doesn't exist,
// input is null cast to HTMLInputElement
// Accessing .value crashes at runtime
console.log(input.value);

const data = JSON.parse(raw) as User;
// No runtime validation, just a promise

Prefer
const input = document.getElementById("search");

if (input instanceof HTMLInputElement) {
  // Safely narrowed to HTMLInputElement
  console.log(input.value);
} else {
  console.error("Search input not found");
}

// For parsed data, validate the shape:
function isUser(val: unknown): val is User {
  return (
    val !== null &&
    typeof val === "object" &&
    "name" in val &&
    "email" in val
  );
}
const parsed: unknown = JSON.parse(raw);
if (isUser(parsed)) {
  console.log(parsed.name); // Safe
}
const input = document.getElementById("search");

if (input instanceof HTMLInputElement) {
  // Safely narrowed to HTMLInputElement
  console.log(input.value);
} else {
  console.error("Search input not found");
}

// For parsed data, validate the shape:
function isUser(val: unknown): val is User {
  return (
    val !== null &&
    typeof val === "object" &&
    "name" in val &&
    "email" in val
  );
}
const parsed: unknown = JSON.parse(raw);
if (isUser(parsed)) {
  console.log(parsed.name); // Safe
}
Why avoid

Type assertions override the compiler without any runtime verification. If the element is null or not an HTMLInputElement, the assertion still compiles. The crash happens at runtime when you access .value on null. Assertions should be a last resort, not the default approach.

Why prefer

Type assertions (as) tell TypeScript to trust you, but they perform no runtime checks. Using instanceof narrows the type safely because it actually checks the value at runtime. For parsed JSON, a type guard validates the shape before trusting the data.

TypeScript: Type Assertions
Avoid
// @ts-ignore
const port: number = "3000";

// Later, someone fixes the value:
// const port: number = 3000;
// The @ts-ignore stays, silently hiding
// any future error on this line
// @ts-ignore
const port: number = "3000";

// Later, someone fixes the value:
// const port: number = 3000;
// The @ts-ignore stays, silently hiding
// any future error on this line

Prefer
// @ts-expect-error: testing invalid assignment
const port: number = "3000";

// Later, when the line is fixed:
// const port: number = 3000;
// TypeScript reports: "Unused @ts-expect-error"
// so you know to remove the directive
// @ts-expect-error: testing invalid assignment
const port: number = "3000";

// Later, when the line is fixed:
// const port: number = 3000;
// TypeScript reports: "Unused @ts-expect-error"
// so you know to remove the directive
Why avoid

@ts-ignore silently suppresses any error on the next line, forever. If the original issue is fixed but a new, different error appears on the same line, @ts-ignore hides it. Over time, codebases accumulate @ts-ignore comments that mask real bugs.

Why prefer

@ts-expect-error requires the next line to have an error. If the error disappears (because someone fixed the code), TypeScript flags the unused directive. This makes it self-cleaning: you never end up with stale suppressions hiding real issues.

Total TypeScript: @ts-expect-error
Avoid
function countWords(text: string): object {
  const counts: object = {};
  for (const word of text.split(" ")) {
    // Error: Element implicitly has an
    // 'any' type because 'object' has
    // no index signature
    counts[word] = (counts[word] || 0) + 1;
  }
  return counts;
}
function countWords(text: string): object {
  const counts: object = {};
  for (const word of text.split(" ")) {
    // Error: Element implicitly has an
    // 'any' type because 'object' has
    // no index signature
    counts[word] = (counts[word] || 0) + 1;
  }
  return counts;
}

Prefer
function countWords(
  text: string
): Record<string, number> {
  const counts: Record<string, number> = {};
  for (const word of text.split(" ")) {
    counts[word] = (counts[word] || 0) + 1;
    // OK: Record<string, number> has an
    // index signature for string keys
  }
  return counts;
}

// Even better with Map:
// const counts = new Map<string, number>();
function countWords(
  text: string
): Record<string, number> {
  const counts: Record<string, number> = {};
  for (const word of text.split(" ")) {
    counts[word] = (counts[word] || 0) + 1;
    // OK: Record<string, number> has an
    // index signature for string keys
  }
  return counts;
}

// Even better with Map:
// const counts = new Map<string, number>();
Why avoid

The object type is almost never what you want for dictionaries. It prevents indexing with brackets and provides no information about the value types. Most uses of object should be replaced with Record, a specific interface, or unknown (if you truly don't know the shape).

Why prefer

The object type means 'any non-primitive' but has no index signature, so you cannot use bracket notation. Record<string, number> creates an index signature that allows string keys with number values. For dynamic key-value collections, Map<K, V> is another good option.

TypeScript: Record<K, T>
Avoid
// "{}" does NOT mean "empty object"
function process(value: {}) {
  console.log(value);
}

// All of these are allowed!
process("hello");   // string
process(42);        // number
process(true);      // boolean
process([1, 2, 3]); // array
// {} means "any non-nullish value"
// "{}" does NOT mean "empty object"
function process(value: {}) {
  console.log(value);
}

// All of these are allowed!
process("hello");   // string
process(42);        // number
process(true);      // boolean
process([1, 2, 3]); // array
// {} means "any non-nullish value"

Prefer
// For "any non-nullish value":
function process(value: NonNullable<unknown>) {
  console.log(value);
}

// For an actual empty object:
type EmptyObject = Record<string, never>;
function processEmpty(value: EmptyObject) {
  // Only accepts {}
}

// For objects with known shape:
interface Config {
  debug: boolean;
  logLevel: string;
}
function processConfig(value: Config) {
  console.log(value.debug);
}
// For "any non-nullish value":
function process(value: NonNullable<unknown>) {
  console.log(value);
}

// For an actual empty object:
type EmptyObject = Record<string, never>;
function processEmpty(value: EmptyObject) {
  // Only accepts {}
}

// For objects with known shape:
interface Config {
  debug: boolean;
  logLevel: string;
}
function processConfig(value: Config) {
  console.log(value.debug);
}
Why avoid

Using {} when you mean 'empty object' is misleading because it accepts all non-nullish values. This is a common source of confusion. TypeScript's structural type system means {} is satisfied by anything with zero or more properties, which includes all primitives except null and undefined.

Why prefer

The {} type in TypeScript means 'any value that is not null or undefined.' It accepts strings, numbers, booleans, and objects. For an actual empty object, use Record<string, never>. For 'any non-nullish value', use NonNullable<unknown> which is more explicit about the intent.

Total TypeScript: The Empty Object Type
Avoid
const config = { host: "localhost", port: 3000, debug: true };

Object.keys(config).forEach((key) => {
  // key is 'string', not keyof typeof config
  console.log(config[key]); // Error
});
const config = { host: "localhost", port: 3000, debug: true };

Object.keys(config).forEach((key) => {
  // key is 'string', not keyof typeof config
  console.log(config[key]); // Error
});

Prefer
const objectKeys = <T extends object>(
  obj: T
): (keyof T)[] => {
  return Object.keys(obj) as (keyof T)[];
};

const config = { host: "localhost", port: 3000, debug: true };

objectKeys(config).forEach((key) => {
  console.log(config[key]); // OK
});
const objectKeys = <T extends object>(
  obj: T
): (keyof T)[] => {
  return Object.keys(obj) as (keyof T)[];
};

const config = { host: "localhost", port: 3000, debug: true };

objectKeys(config).forEach((key) => {
  console.log(config[key]); // OK
});
Why avoid

Object.keys returns string[] by design because TypeScript's type system is structural: an object can have more keys at runtime than its type declares. While this is technically correct, it makes key iteration painful. A typed wrapper trades that theoretical safety for practical usability.

Why prefer

A generic objectKeys wrapper returns (keyof T)[] instead of string[]. This gives you literal key types when iterating, so config[key] is type-safe. The as assertion is safe here because Object.keys does return the object's own keys at runtime.

Total TypeScript: Type-safe Object.keys
Avoid
// No return type annotation
export function createUser(name: string) {
  return {
    id: crypto.randomUUID(),
    name,
    createdAt: new Date(),
    role: "user" as const,
  };
}

// Return type is inferred, but:
// 1. Changing internals silently changes
//    the public API type
// 2. Error messages point to callers,
//    not the function definition
// No return type annotation
export function createUser(name: string) {
  return {
    id: crypto.randomUUID(),
    name,
    createdAt: new Date(),
    role: "user" as const,
  };
}

// Return type is inferred, but:
// 1. Changing internals silently changes
//    the public API type
// 2. Error messages point to callers,
//    not the function definition

Prefer
interface User {
  id: string;
  name: string;
  createdAt: Date;
  role: "user" | "admin";
}

export function createUser(name: string): User {
  return {
    id: crypto.randomUUID(),
    name,
    createdAt: new Date(),
    role: "user",
  };
}

// Benefits:
// 1. Public contract is explicit
// 2. Internal changes that break the
//    contract show errors HERE, not in
//    every file that imports the function
interface User {
  id: string;
  name: string;
  createdAt: Date;
  role: "user" | "admin";
}

export function createUser(name: string): User {
  return {
    id: crypto.randomUUID(),
    name,
    createdAt: new Date(),
    role: "user",
  };
}

// Benefits:
// 1. Public contract is explicit
// 2. Internal changes that break the
//    contract show errors HERE, not in
//    every file that imports the function
Why avoid

Relying on inference for exported functions means the public API type changes silently when internals change. Removing a property, changing a type, or renaming a field causes errors in every file that imports the function, making it hard to trace the root cause.

Why prefer

Explicit return types on exported functions create a stable public contract. If you accidentally change the return shape, the error appears at the function definition, not in every consuming file. For private/local functions, inference is fine because both the definition and usage are nearby.

TypeScript: Return Type Annotations
Avoid
interface FormData {
  name: string;
  nickname?: string;
}

// These are treated the same:
const a: FormData = { name: "Alice" };
const b: FormData = {
  name: "Alice",
  nickname: undefined,
};

// But they behave differently:
"nickname" in a; // false
"nickname" in b; // true
Object.keys(a);  // ["name"]
Object.keys(b);  // ["name", "nickname"]
interface FormData {
  name: string;
  nickname?: string;
}

// These are treated the same:
const a: FormData = { name: "Alice" };
const b: FormData = {
  name: "Alice",
  nickname: undefined,
};

// But they behave differently:
"nickname" in a; // false
"nickname" in b; // true
Object.keys(a);  // ["name"]
Object.keys(b);  // ["name", "nickname"]

Prefer
// Distinguish missing from explicit undefined
// with exactOptionalPropertyTypes: true

interface FormData {
  name: string;
  // Can be missing (not provided)
  nickname?: string;
}

interface FormPatch {
  name?: string;
  // Can be explicitly set to undefined
  // (to clear the value)
  nickname?: string | undefined;
}

// patch.nickname = undefined means "clear it"
// Missing nickname means "don't change it"
function applyPatch(
  data: FormData,
  patch: FormPatch
): FormData {
  return { ...data, ...patch };
}
// Distinguish missing from explicit undefined
// with exactOptionalPropertyTypes: true

interface FormData {
  name: string;
  // Can be missing (not provided)
  nickname?: string;
}

interface FormPatch {
  name?: string;
  // Can be explicitly set to undefined
  // (to clear the value)
  nickname?: string | undefined;
}

// patch.nickname = undefined means "clear it"
// Missing nickname means "don't change it"
function applyPatch(
  data: FormData,
  patch: FormPatch
): FormData {
  return { ...data, ...patch };
}
Why avoid

Treating optional and undefined as equivalent hides a real semantic difference. In a PATCH request, omitting a field means 'keep the current value,' while sending undefined means 'clear this field.' Without distinguishing the two, you cannot express 'do not change' at the type level.

Why prefer

Optional properties (?) and properties that accept undefined have different semantics. A missing property means 'not specified,' while an explicit undefined means 'intentionally cleared.' This distinction matters for PATCH APIs, form handling, and serialization. The exactOptionalPropertyTypes flag enforces this difference.

TypeScript: exactOptionalPropertyTypes