DEV Community

Cover image for TypeScript Threw Away My Error Types — Here's What I Use Instead
Gabriel Anhaia
Gabriel Anhaia

Posted on

TypeScript Threw Away My Error Types — Here's What I Use Instead


Java has checked exceptions. PHP has try/catch with a hierarchy of Throwable, Exception, and Error. C# has System.Exception with inner exceptions. TypeScript has... nothing. No throws clause. No exception hierarchy. The catch block gives you unknown and wishes you luck.

This was the hardest adjustment for me. I came from languages where error handling had structure. TypeScript just lets errors fly and trusts you to figure it out.

After a year of backend TypeScript, I've found patterns that work. Some are better than what Java gives you. This post covers async code, error handling, runtime validation, and the runtime patterns that actually matter in production.

Promises and async/await

If you've used Kotlin coroutines or Java's CompletableFuture, the shape of async TypeScript will feel familiar. The syntax is cleaner than both.

// a function that returns a Promise
async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  return data as User; // unsafe cast — we'll fix this with Zod later
}

// calling it
const user = await fetchUser("abc-123");
console.log(user.name);
Enter fullscreen mode Exit fullscreen mode

The async keyword marks a function as asynchronous. It always returns a Promise<T>. The await keyword pauses execution until the promise resolves. TypeScript infers the return type as Promise<User> even if you don't annotate it, but I recommend being explicit. It makes the contract clear to anyone reading the code.

The Java equivalent for comparison:

// Java — CompletableFuture
public CompletableFuture<User> fetchUser(String id) {
    return httpClient.sendAsync(
        HttpRequest.newBuilder()
            .uri(URI.create("/api/users/" + id))
            .build(),
        HttpResponse.BodyHandlers.ofString()
    ).thenApply(response -> parseUser(response.body()));
}

// calling it
User user = fetchUser("abc-123").get(); // blocks
Enter fullscreen mode Exit fullscreen mode

And the Kotlin version:

// Kotlin — coroutines
suspend fun fetchUser(id: String): User {
    val response = httpClient.get("/api/users/$id")
    return response.body()
}

// calling it
val user = fetchUser("abc-123")
Enter fullscreen mode Exit fullscreen mode

TypeScript's version reads almost identically to Kotlin's. The main difference: Kotlin needs a coroutine scope to launch suspend functions. TypeScript lets you await at the top level in ESM modules. Less ceremony.

You can run promises in parallel with Promise.all, which is something you'll do constantly in backend code:

// fetch multiple things at once — don't await them sequentially
const [user, orders, preferences] = await Promise.all([
  fetchUser(userId),
  fetchOrders(userId),
  fetchPreferences(userId),
]);
Enter fullscreen mode Exit fullscreen mode

There's also Promise.allSettled (keeps going even if some promises reject) and Promise.race (first one wins). These are your concurrency primitives. No ExecutorService or thread pool to configure.

The Event Loop: What Backend Devs Need to Know

I'm not going to explain microtasks and macrotasks. This is the mental model you need to stop writing slow Node.js code.

Node.js runs on a single thread. One. Not a thread pool like Tomcat. Not process-per-request like traditional PHP. One thread processes all your requests, and it never blocks on I/O.

When you call await fetch(...), your code doesn't sit on a thread waiting for the network response. The runtime registers a callback, frees the thread for other work, and picks your code back up when the response arrives. This is why a single Node.js process handles thousands of concurrent connections. It's never waiting, always doing something.

The Java comparison makes this clearer:

// Java — thread-per-request model
// Each request gets its own thread. 200 concurrent requests = 200 threads.
// Thread sits idle during I/O (database query, HTTP call, file read).
public User handleRequest(String userId) {
    User user = database.findById(userId); // thread blocks here
    List<Order> orders = orderService.getOrders(userId); // blocks again
    return enrichUser(user, orders);
}
Enter fullscreen mode Exit fullscreen mode
// TypeScript/Node — single thread, non-blocking
// One thread handles all requests. During I/O, it works on other requests.
async function handleRequest(userId: string): Promise<User> {
  const user = await database.findById(userId); // thread freed during I/O
  const orders = await orderService.getOrders(userId); // freed again
  return enrichUser(user, orders);
}
Enter fullscreen mode Exit fullscreen mode

Both snippets look similar. The difference is what happens while waiting. Java's thread sleeps. Node's thread handles other requests.

This has one big consequence: never block the event loop. A CPU-intensive loop, a synchronous file read, any long-running synchronous operation freezes every request. Not just the current one. All of them.

// DON'T do this in a request handler
function hashPassword(password: string): string {
  // bcrypt's synchronous version blocks the entire event loop
  return bcryptSync(password, 12); // every other request waits
}

// DO this instead
async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, 12); // async, event loop stays free
}
Enter fullscreen mode Exit fullscreen mode

One thing that confused me early on: await is not Thread.sleep(). In Java, Thread.sleep(1000) blocks the thread for one second. Nothing happens on that thread. In Node, await new Promise(resolve => setTimeout(resolve, 1000)) schedules a timer and frees the thread immediately. Other requests keep flowing.

Error Handling: The Wild West

This is where TypeScript gets uncomfortable.

In Java, you declare what a method can throw:

// Java — the compiler enforces error handling
public User findUser(String id) throws UserNotFoundException, DatabaseException {
    // ...
}

// caller MUST handle these
try {
    User user = findUser("abc");
} catch (UserNotFoundException e) {
    return Response.status(404).build();
} catch (DatabaseException e) {
    return Response.status(500).build();
}
Enter fullscreen mode Exit fullscreen mode

TypeScript has none of this. Any function can throw anything at any time. The compiler doesn't know. The caller doesn't know. No throws clause.

// this function can throw. good luck figuring that out from the signature.
async function findUser(id: string): Promise<User> {
  const row = await db.query("SELECT * FROM users WHERE id = $1", [id]);
  if (!row) {
    throw new Error("User not found"); // surprise!
  }
  return mapToUser(row);
}
Enter fullscreen mode Exit fullscreen mode

When you do catch an error, TypeScript (in strict mode) types it as unknown. Not Error. Not Exception. unknown.

try {
  const user = await findUser("abc");
} catch (err) {
  // err is `unknown` — you can't access .message without narrowing
  console.log(err.message); // ERROR: 'err' is of type 'unknown'

  // you have to check first
  if (err instanceof Error) {
    console.log(err.message); // now it's fine
  }
}
Enter fullscreen mode Exit fullscreen mode

This is safer than the old behavior (where catch gave you any), but it's verbose. And it still doesn't solve the real problem: you have no idea what a function might throw without reading its implementation.

I spent my first month littering try/catch blocks everywhere, feeling uneasy the entire time. There's a better way.

Pattern: Typed Error Handling with Discriminated Unions

Instead of throwing exceptions, return errors as values. This is the single most useful pattern for backend TypeScript.

// define a Result type
type Result<T, E = Error> =
  | { ok: true; data: T }
  | { ok: false; error: E };
Enter fullscreen mode Exit fullscreen mode

That's it. Two possible shapes. The ok field is a discriminant that TypeScript can narrow on. Now use it:

// specific error types for your domain
type UserError =
  | { code: "NOT_FOUND"; userId: string }
  | { code: "SUSPENDED"; reason: string }
  | { code: "DB_ERROR"; cause: string };

async function findUser(id: string): Promise<Result<User, UserError>> {
  try {
    const row = await db.query("SELECT * FROM users WHERE id = $1", [id]);

    if (!row) {
      return { ok: false, error: { code: "NOT_FOUND", userId: id } };
    }

    if (row.status === "suspended") {
      return {
        ok: false,
        error: { code: "SUSPENDED", reason: row.suspensionReason },
      };
    }

    return { ok: true, data: mapToUser(row) };
  } catch (e) {
    return {
      ok: false,
      error: { code: "DB_ERROR", cause: e instanceof Error ? e.message : "Unknown" },
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The caller now has complete type safety:

const result = await findUser("abc-123");

if (!result.ok) {
  // TypeScript knows result.error exists and has the UserError type
  switch (result.error.code) {
    case "NOT_FOUND":
      return res.status(404).json({ message: `User ${result.error.userId} not found` });
    case "SUSPENDED":
      return res.status(403).json({ message: result.error.reason });
    case "DB_ERROR":
      return res.status(500).json({ message: "Internal error" });
  }
}

// TypeScript knows result.data is User here
console.log(result.data.name);
Enter fullscreen mode Exit fullscreen mode

If you add a new error code to UserError, the compiler flags every switch statement that doesn't handle it (when you use exhaustiveness checks). That's better than Java's checked exceptions because it works with the type system instead of fighting it.

This pattern isn't original to TypeScript. Rust's Result<T, E> works the same way. Kotlin has its own Result<T> type (though it's more limited, with a single Throwable error type). Go returns (value, error) tuples. The idea of errors-as-values is well established, and TypeScript's discriminated unions make it ergonomic.

You can build a couple of helper functions to reduce boilerplate:

function ok<T>(data: T): Result<T, never> {
  return { ok: true, data };
}

function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

// now the findUser function reads cleaner
async function findUser(id: string): Promise<Result<User, UserError>> {
  const row = await db.query("SELECT * FROM users WHERE id = $1", [id]);
  if (!row) return err({ code: "NOT_FOUND", userId: id });
  return ok(mapToUser(row));
}
Enter fullscreen mode Exit fullscreen mode

I don't use a library for this. The type is four lines, the helpers are two functions. No reason to pull in a dependency.

Pattern: Zod Validation at Boundaries

Post 1 covered type erasure: TypeScript's types don't exist at runtime. So what happens when data crosses your application boundary? An HTTP request body, an environment variable, a database row, a message from a queue. The types you wrote are just your hope about what that data looks like.

This is where Zod comes in. Zod lets you define a schema that validates at runtime and infers a TypeScript type. You write the shape once and get both.

npm install zod
Enter fullscreen mode Exit fullscreen mode

Validating request bodies:

import { z } from "zod";

// define the schema — this exists at runtime
const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(["admin", "viewer", "editor"]),
  age: z.number().int().min(18).optional(),
});

// infer the TypeScript type from the schema — zero duplication
type CreateUserRequest = z.infer<typeof CreateUserSchema>;
// equivalent to: { name: string; email: string; role: "admin" | "viewer" | "editor"; age?: number }

// use it in your handler
async function handleCreateUser(req: Request): Promise<Response> {
  const parsed = CreateUserSchema.safeParse(await req.json());

  if (!parsed.success) {
    // parsed.error has detailed info about what failed
    return Response.json({ errors: parsed.error.issues }, { status: 400 });
  }

  // parsed.data is fully typed as CreateUserRequest
  const user = await createUser(parsed.data);
  return Response.json(user, { status: 201 });
}
Enter fullscreen mode Exit fullscreen mode

Notice safeParse instead of parse. The parse method throws on invalid data. safeParse returns a result object, which fits perfectly with the Result pattern from the previous section.

Typing process.env:

Environment variables are all string | undefined by default. That's not useful. Validate them at startup:

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number().int().default(3000),
  NODE_ENV: z.enum(["development", "staging", "production"]),
  API_KEY: z.string().min(1),
});

// validate once at startup — crash early if config is wrong
const env = EnvSchema.parse(process.env);

// now env.PORT is number, env.DATABASE_URL is string, etc.
// fully typed, validated, with defaults applied
Enter fullscreen mode Exit fullscreen mode

If DATABASE_URL is missing or PORT isn't a number, your app fails immediately with a clear error message instead of crashing twenty minutes later in some random function.

Parsing external API responses:

const GitHubUserSchema = z.object({
  login: z.string(),
  id: z.number(),
  avatar_url: z.string().url(),
  // ignore extra fields — Zod strips them by default
});

type GitHubUser = z.infer<typeof GitHubUserSchema>;

async function fetchGitHubUser(username: string): Promise<Result<GitHubUser, string>> {
  const response = await fetch(`https://api.github.com/users/${username}`);

  if (!response.ok) {
    return err(`GitHub API returned ${response.status}`);
  }

  const parsed = GitHubUserSchema.safeParse(await response.json());

  if (!parsed.success) {
    return err("Unexpected response shape from GitHub API");
  }

  return ok(parsed.data);
}
Enter fullscreen mode Exit fullscreen mode

The philosophy: validate at the boundary, trust the types inside. Every piece of data entering your system gets validated by Zod. Once it passes, you have a proper TypeScript type and can stop worrying about runtime surprises.

Coming from Java, this replaces @Valid, Bean Validation annotations, and Jackson deserialization. It's more explicit, and I've come to prefer it.

The Temporal API

Dates in JavaScript have been terrible since 1995. The Date object is mutable, months are zero-indexed, timezone handling is a nightmare, and everyone just installs date-fns or dayjs and pretends the problem is solved.

TypeScript 6.0 ships type definitions for the Temporal API, which reached Stage 4 in ECMAScript. If you've used java.time (LocalDate, ZonedDateTime, Duration), Temporal will feel immediately familiar. Same design inspiration.

import { Temporal } from "temporal-polyfill"; // polyfill still needed for most runtimes

// PlainDate — like Java's LocalDate. No time, no timezone.
const today = Temporal.Now.plainDateISO();
const deadline = Temporal.PlainDate.from("2026-12-31");
const daysLeft = today.until(deadline).days;
console.log(`${daysLeft} days until deadline`);

// PlainDateTime — like LocalDateTime. Date + time, no timezone.
const meeting = Temporal.PlainDateTime.from("2026-04-15T14:30:00");

// ZonedDateTime — like Java's ZonedDateTime. The full picture.
const nyTime = Temporal.Now.zonedDateTimeISO("America/New_York");
const tokyoTime = nyTime.withTimeZone("Asia/Tokyo");
console.log(`NY: ${nyTime.toPlainTime()}`);
console.log(`Tokyo: ${tokyoTime.toPlainTime()}`);
Enter fullscreen mode Exit fullscreen mode

The concepts map from Java like this:

Java (java.time) Temporal API What it represents
LocalDate Temporal.PlainDate Date without time or zone
LocalTime Temporal.PlainTime Time without date or zone
LocalDateTime Temporal.PlainDateTime Date + time, no zone
ZonedDateTime Temporal.ZonedDateTime Full date/time with timezone
Duration Temporal.Duration Length of time
Period Temporal.Duration (combined) Calendar-based duration
Instant Temporal.Instant Point on the timeline (UTC)

The big wins over Date: Temporal objects are immutable (like java.time). Months are 1-indexed (January is 1, finally). Arithmetic is explicit:

const today = Temporal.Now.plainDateISO();

// add 30 days
const future = today.add({ days: 30 });

// add 2 months and 1 week
const later = today.add({ months: 2, weeks: 1 });

// compare dates
if (Temporal.PlainDate.compare(today, deadline) < 0) {
  console.log("still have time");
}
Enter fullscreen mode Exit fullscreen mode

No more date.setMonth(date.getMonth() + 1) and praying the day doesn't overflow. No more "is month 3 March or April?" confusion.

Practical note: as of early 2026, most runtimes still need a polyfill (temporal-polyfill or @js-temporal/polyfill). Node.js has it behind --experimental-temporal. The types are in TypeScript 6.0 already, so your code is ready when native support lands.

Node.js Native TypeScript Execution

Running TypeScript used to mean a build step: compile .ts to .js, then run the .js. Or use ts-node / tsx for development. Node.js now has built-in TypeScript support via type stripping.

# run a .ts file directly — Node 22.6+
node --experimental-strip-types src/server.ts
Enter fullscreen mode Exit fullscreen mode

What this does: Node reads your .ts file, strips all type annotations (just removes them, no type checking), and executes the resulting JavaScript. It's fast because there's no compilation. It literally just deletes the type syntax.

What it handles:

  • Type annotations (const x: number = 5 becomes const x = 5)
  • Interfaces and type aliases (removed entirely)
  • Generics (removed)
  • Return type annotations (removed)

What it does not handle:

  • Enums (they generate runtime code)
  • Namespaces (also generate runtime code)
  • Decorators with emitDecoratorMetadata
  • .tsx files
  • Paths/aliases from tsconfig.json

For those features, you still need tsx (the community tool, not the file extension) or a proper build step:

# tsx handles everything, including enums and paths
npx tsx src/server.ts

# or for production, compile normally
npx tsc && node dist/server.js
Enter fullscreen mode Exit fullscreen mode

My setup: node --experimental-strip-types for quick scripts and simple services during development. For production, I compile with tsc. Type stripping is convenient, but "no type checking" means you could deploy broken types and only find out at runtime. The build step catches that.

One more thing: in Node 23+, the --experimental-strip-types flag graduated to stable, so you can drop the --experimental prefix:

node --strip-types src/server.ts
Enter fullscreen mode Exit fullscreen mode

Putting It Together

A user service combining typed errors, Zod validation, and async operations:

import { z } from "zod";

// --- Zod schema for input validation ---
const CreateUserInput = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
});

type CreateUserInput = z.infer<typeof CreateUserInput>;

// --- Typed errors ---
type ServiceError =
  | { code: "VALIDATION_ERROR"; issues: z.ZodIssue[] }
  | { code: "DUPLICATE_EMAIL"; email: string }
  | { code: "DB_ERROR"; cause: string };

type Result<T, E = ServiceError> =
  | { ok: true; data: T }
  | { ok: false; error: E };

// --- The service function ---
async function createUser(raw: unknown): Promise<Result<User>> {
  // validate input at the boundary
  const parsed = CreateUserInput.safeParse(raw);
  if (!parsed.success) {
    return { ok: false, error: { code: "VALIDATION_ERROR", issues: parsed.error.issues } };
  }

  // check for duplicates
  const existing = await db.findByEmail(parsed.data.email);
  if (existing) {
    return { ok: false, error: { code: "DUPLICATE_EMAIL", email: parsed.data.email } };
  }

  // create the user
  try {
    const user = await db.insert({
      name: parsed.data.name,
      email: parsed.data.email,
      createdAt: Temporal.Now.instant().toString(),
    });
    return { ok: true, data: user };
  } catch (e) {
    return {
      ok: false,
      error: { code: "DB_ERROR", cause: e instanceof Error ? e.message : "Unknown" },
    };
  }
}

// --- Route handler ---
async function handleCreateUser(req: Request): Promise<Response> {
  const result = await createUser(await req.json());

  if (!result.ok) {
    switch (result.error.code) {
      case "VALIDATION_ERROR":
        return Response.json({ errors: result.error.issues }, { status: 400 });
      case "DUPLICATE_EMAIL":
        return Response.json(
          { message: `Email ${result.error.email} already registered` },
          { status: 409 },
        );
      case "DB_ERROR":
        return Response.json({ message: "Internal server error" }, { status: 500 });
    }
  }

  return Response.json(result.data, { status: 201 });
}
Enter fullscreen mode Exit fullscreen mode

No exceptions flying across layers. Every error is typed and handled explicitly. The input gets validated before it touches business logic. This is the style of backend TypeScript I've settled into, and it's more predictable than the try/catch chains I used to write in Java.

What's Coming Next

In Post 7, we'll wrap up with project setup and configuration: tsconfig.json options that actually matter, ESLint and Prettier, monorepos, and the overall structure of a production TypeScript backend project.

How do you handle errors in your TypeScript backend? Result types, thrown exceptions, a library like neverthrow? I'm curious what patterns other backend devs have landed on. Drop a comment.

I'm building Hermes IDE, an open-source AI-powered dev tool built with TypeScript and Rust. If you want to see these patterns in a real codebase, check it out on GitHub. A star helps a lot. You can follow my work at gabrielanhaia.

Top comments (0)