Skip to main content

TypeScript Error Types and How to Fix Them

TypeScript errors come in two flavors that need different solutions. Compile-time errors -- the red squiggles from TS2345 or TS2307 -- are about types not lining up. Runtime error handling is about what happens when something actually throws and you need to work with the error safely.

Most developers know about the first category. The second is where things get tricky. TypeScript types caught errors as unknown, which means you can't call error.message without a check first. The built-in error hierarchy isn't always obvious either. This guide covers both sides: how to read and fix compiler errors, and how to build error handling that's actually type-safe at runtime.

Typing Errors in Catch Blocks

Since TypeScript 4.0, the error variable in a catch block defaults to unknown. That means accessing error.message directly will fail to compile -- TypeScript won't let you assume it's an Error object, because anything can be thrown in JavaScript: strings, numbers, plain objects, null.

The fix is to narrow the type first with instanceof:

try {
await fetchUserData(userId);
} catch (error) {
if (error instanceof Error) {
console.log(error.message); // TypeScript now knows this is safe
} else {
console.log("An unexpected error occurred:", String(error));
}
}

This is the baseline pattern. For a full deep-dive -- including a reusable getErrorMessage utility, custom type guards for third-party errors, and how to choose between approaches -- see TypeScript catch error type. The TypeScript try/catch guide covers finally, async patterns, and when not to use try/catch at all.

Custom Error Classes in TypeScript

Extending the built-in Error class lets you attach structured data to your errors and use instanceof to discriminate between them in catch blocks. Instead of parsing error messages to figure out what went wrong, the type of the error tells you directly.

Here's a pattern for two domain-specific errors in a typical backend service:

class AuthError extends Error {
constructor(
message: string,
public requiredPermission: string
) {
super(message);
this.name = "AuthError";
Object.setPrototypeOf(this, AuthError.prototype); // needed for ES5 targets
}
}

class DatabaseError extends Error {
constructor(
message: string,
public table: string,
public operation: "read" | "write" | "delete"
) {
super(message);
this.name = "DatabaseError";
Object.setPrototypeOf(this, DatabaseError.prototype);
}
}

Now catch blocks can respond to each failure mode specifically, instead of applying the same generic handler to everything:

async function deletePost(postId: string, userId: string): Promise<void> {
const post = await db.posts.findById(postId);

if (!post) {
throw new DatabaseError(`Post ${postId} not found`, "posts", "read");
}

if (post.authorId !== userId) {
throw new AuthError("Only the post author can delete this", "post:delete");
}

await db.posts.delete(postId);
}

try {
await deletePost(postId, currentUser.id);
} catch (error) {
if (error instanceof AuthError) {
return res.status(403).json({ error: error.message, required: error.requiredPermission });
} else if (error instanceof DatabaseError) {
logger.error(`DB ${error.operation} failed on ${error.table}:`, error.message);
return res.status(500).json({ error: "Database error" });
}
throw error; // Re-throw anything unexpected
}

Two notes on the constructor setup: this.name should match the class name so your stack traces are readable. The Object.setPrototypeOf call is necessary when compiling to ES5 -- without it, instanceof checks against your subclass silently return false. See TypeScript catch error type for a full explanation of why this happens.

This pattern pairs well with TypeScript discriminated unions when you want to model errors as data rather than exceptions.

Built-in JavaScript Error Subtypes

TypeScript and the JavaScript runtime both use several built-in error subtypes. Knowing which one to throw gives your catch blocks more to work with and makes stack traces easier to read.

Error TypeWhen it's thrown
TypeErrorWrong type passed (e.g., calling a non-function)
RangeErrorValue outside the allowed range (e.g., invalid array length)
ReferenceErrorAccessing a variable that doesn't exist
SyntaxErrorUsually from JSON.parse or eval with malformed input
URIErrorMalformed URI in decodeURIComponent

You'll encounter these in catch blocks when integrating with external APIs or parsing data:

function parseConfig(raw: string): AppConfig {
try {
return JSON.parse(raw) as AppConfig;
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`Invalid config format: ${error.message}`);
}
throw error;
}
}

function getItemAtIndex<T>(arr: T[], index: number): T {
if (index < 0 || index >= arr.length) {
// Throw RangeError for out-of-bounds, matching JS array semantics
throw new RangeError(`Index ${index} is out of bounds for array of length ${arr.length}`);
}
return arr[index];
}

Chaining Errors with Error.cause

TypeScript 4.6 added support for the cause property from ES2022. When you catch a low-level error and re-throw a higher-level one, cause lets you attach the original so nothing gets lost.

In a layered application, errors travel through several functions before surfacing. Without cause, every re-throw drops the context from the level below:

async function loadUserPreferences(userId: string) {
try {
return await db.query(`SELECT * FROM preferences WHERE user_id = $1`, [userId]);
} catch (error) {
// Wrap the DB error with context about what we were trying to do
throw new Error(`Failed to load preferences for user ${userId}`, { cause: error });
}
}

async function buildUserDashboard(userId: string) {
try {
const prefs = await loadUserPreferences(userId);
return assembleDashboard(prefs);
} catch (error) {
// Wrap again at the feature level -- cause chain now has two levels of context
throw new Error("Dashboard unavailable", { cause: error });
}
}

// When this reaches your error handler:
try {
await buildUserDashboard(userId);
} catch (error) {
if (error instanceof Error) {
console.error(error.message); // "Dashboard unavailable"
console.error(error.cause); // Error: "Failed to load preferences for user ..."
// error.cause.cause is the original DB error
}
}

Each layer adds context without losing what the layer below captured. Instead of a generic "something failed" with no trail, you get the full chain from feature down to root cause -- which makes diagnosing production failures much faster.

This is especially useful in full-stack applications where an error at the database layer needs to bubble up through your API handlers without losing context.

Null and Undefined Errors

Null and undefined crashes are among the most frequent runtime errors, and most of them are preventable at compile time. Optional chaining (?.) lets you safely access nested properties without an explicit null check at every level:

const user = {
name: "Jane",
address: {
street: "123 Main St"
}
};

console.log(user.address?.street); // "123 Main St"
console.log(user.address?.city); // undefined (no crash)

You can also use nullish coalescing (??) to provide a fallback when a value is null or undefined:

const displayName = user.profile?.displayName ?? "Anonymous";

For more on working with optional values, see TypeScript optional chaining.

Resolving TypeScript Type Conflicts

When two libraries define a type with the same name, type aliases give you a clean way to distinguish them:

type UserA = import('library-a').User;
type UserB = import('library-b').User;

You can also merge partial type definitions from different libraries using the & operator:

type User = import('library-a').User & import('library-b').User;

This approach works well alongside TypeScript utility types when you need to compose or constrain merged types further.

Reading TypeScript Error Messages

TypeScript error messages always follow the same structure: an error code, a short description, and a location. Learning to read them quickly saves a lot of debugging time.

error TS2307: Cannot find module './models/User' or its corresponding type declarations.

The TS2307 code is searchable -- you can look it up in the TypeScript documentation or just search for it directly. The description tells you what went wrong, and the file/line tells you where.

When you're working with TypeScript type assertions, errors will usually show you both the expected and actual types side by side. For errors involving TypeScript keyof, the message will name the exact property that doesn't exist on the type.

For complex type errors, add an intermediate type annotation to pin down exactly where the mismatch is:

// Narrow down where the error originates
const result = complexFunction(); // error is somewhere in here...

// Add a type annotation to pin down the mismatch
const typed: ExpectedType = complexFunction(); // now the error points here specifically

Configuring TypeScript to Catch Errors Early

Enabling Strict Mode

The --strict flag enables several checks that catch the most common errors before they reach production:

{
"compilerOptions": {
"strict": true
}
}

Strict mode enforces null checks, disallows implicit any, and tightens function parameter handling. It's the single most effective tsconfig change you can make when working with complex type hierarchies or TypeScript generics.

Disabling Implicit Any

If you're not ready to enable full strict mode, noImplicitAny is the most impactful individual flag:

{
"compilerOptions": {
"noImplicitAny": true
}
}

This stops TypeScript from silently widening untyped variables to any, which is how most type safety gets lost. It's particularly useful when working with TypeScript discriminated unions, since it forces you to handle all union members explicitly.

Using noEmit During Development

When you want to check for type errors without generating output files, noEmit skips the output step and just reports errors:

{
"compilerOptions": {
"noEmit": true
}
}

This is handy in CI pipelines or when you want to validate types without running a full build. For custom functions in Convex, these settings help maintain end-to-end type safety across your backend and frontend code.

Defining Types to Prevent Errors

Good type definitions catch misuse at the call site, before anything runs. Using interface and type declarations makes bad inputs a compiler error instead of a runtime crash:

interface UserProfile {
id: string;
name: string;
email: string;
createdAt: Date;
}

type UserId = string; // makes intent clear, prevents mixing up string arguments

For projects using Convex, well-defined types provide end-to-end type safety from your database schema to your frontend components -- the same shape is guaranteed throughout the stack.

Avoiding the any Type

The any type turns off TypeScript's checks entirely for that value. Once something is any, type errors can propagate silently:

// Avoid: type safety abandoned entirely
let config: any = loadConfig();
config.nonExistentProperty.doSomething(); // no error at compile time, crash at runtime

// Better: define the shape explicitly
interface AppConfig {
apiUrl: string;
timeout: number;
retries: number;
}

const config: AppConfig = loadConfig();

If you need flexibility, TypeScript generics or utility types are almost always a better choice than reaching for any. For larger codebases, Convex's code spelunking guide has useful patterns for navigating complex TypeScript APIs.

Key Takeaways

A few patterns handle most TypeScript error situations:

  • Use instanceof Error to narrow catch block errors before accessing .message -- never assume the type
  • Build custom error classes with domain-specific properties so catch blocks can respond to each failure mode differently
  • Chain errors with Error.cause when re-throwing -- preserve the full context rather than swapping out the original error
  • Enable strict: true in tsconfig.json -- it catches the most problems with the least configuration
  • Avoid any; use generics or utility types instead when you need flexibility
  • TypeScript error codes like TS2307 and TS2345 are searchable -- look them up directly rather than guessing at the fix

TypeScript's type system catches errors before they ship. These patterns take some getting used to, but once they're habitual you'll spend a lot less time hunting down bugs that could have been compile errors.