DEV Community

Cover image for The Type System: What You Know, What's New, and What's Weird
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Type System: What You Know, What's New, and What's Weird


You'll reach for class hierarchies and abstract classes. Stop. TypeScript has something better for most of those cases.

In Post 1, we covered the big mental shifts: structural typing, type erasure, null vs undefined, how overloading isn't really overloading. That was the "prepare yourself" post. This one is where we actually build things with the type system.

I'll split it by feel: the stuff that'll be instantly familiar, the stuff that's genuinely new, and the stuff that'll trip you up because it looks familiar but behaves differently.

Primitives, Arrays, Objects: The Familiar Stuff

I'll keep this short because you already know what types are.

const name: string = "Gabriel";
const age: number = 31;
const isActive: boolean = true;
Enter fullscreen mode Exit fullscreen mode

No int vs float vs double. It's all number. There's also bigint if you need arbitrary precision, but number covers 99% of cases.

Arrays have two syntaxes:

const ids: number[] = [1, 2, 3];
const names: Array<string> = ["Alice", "Bob"];
Enter fullscreen mode Exit fullscreen mode

Both do the same thing. I use number[] because it's shorter. Some teams prefer Array<string> for consistency with other generic types. Pick one, move on.

Object types look like this:

const user: { id: number; name: string; email: string } = {
  id: 1,
  name: "Gabriel",
  email: "gabriel@example.com",
};
Enter fullscreen mode Exit fullscreen mode

You wouldn't actually inline that type everywhere. You'd extract it. Which brings us to the first real decision you'll face.

Type Aliases vs Interfaces

In Java or C#, you have classes and interfaces. In TypeScript, you have type and interface for describing the shape of data, and they overlap a lot.

// Type alias
type User = {
  id: number;
  name: string;
  email: string;
};

// Interface
interface User {
  id: number;
  name: string;
  email: string;
}
Enter fullscreen mode Exit fullscreen mode

For object shapes, these are nearly interchangeable. Both support extending:

// Type uses intersection
type AdminUser = User & {
  permissions: string[];
};

// Interface uses extends
interface AdminUser extends User {
  permissions: string[];
}
Enter fullscreen mode Exit fullscreen mode

So which one do you pick?

Here's my take: use type for almost everything. Use interface when you specifically need declaration merging (where multiple interface declarations with the same name combine automatically) or when you're designing a public API that other packages will extend.

Declaration merging is a real thing:

interface Window {
  myCustomProperty: string;
}
// This merges with the existing Window interface
// rather than overwriting it
Enter fullscreen mode Exit fullscreen mode

You can't do that with type. But how often do you actually need that? Almost never in application code. type aliases are more flexible: they can represent unions, intersections, primitives, tuples. Interfaces can only describe object shapes.

type ID = string | number;          // Can't do this with an interface
type Pair = [string, number];       // Can't do this either
type Callback = (data: string) => void; // Or this
Enter fullscreen mode Exit fullscreen mode

The TypeScript team has gone back and forth on recommendations over the years. My rule: type by default, interface when you have a specific reason.

Union Types: This Changes Everything

If you come from Java, you've probably written something like this to handle a value that could be one of several types:

// Java: the clunky way
public Object parseInput(String raw) {
    try {
        return Integer.parseInt(raw);
    } catch (NumberFormatException e) {
        return raw;
    }
}
// Now you're stuck with Object and casting everywhere
Enter fullscreen mode Exit fullscreen mode

TypeScript unions solve this directly:

function parseInput(raw: string): number | string {
  const parsed = Number(raw);
  return Number.isNaN(parsed) ? raw : parsed;
}

const result = parseInput("42");
// result is number | string
Enter fullscreen mode Exit fullscreen mode

The compiler tracks the union. You can narrow it with typeof:

if (typeof result === "number") {
  // TypeScript knows result is number here
  console.log(result.toFixed(2));
} else {
  // TypeScript knows result is string here
  console.log(result.toUpperCase());
}
Enter fullscreen mode Exit fullscreen mode

This is called type narrowing, and it's one of the most powerful features in the type system. No casts, no instanceof chains with abstract base classes. The compiler follows your control flow and narrows the type automatically.

A more realistic version, modeling API responses:

type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: string };

function handleResponse(response: ApiResponse<User>) {
  if (response.success) {
    // TypeScript knows response.data exists here
    console.log(response.data.name);
  } else {
    // TypeScript knows response.error exists here
    console.log(response.error);
  }
}
Enter fullscreen mode Exit fullscreen mode

In Java, you'd model this with a sealed interface or a Result<T> class with subclasses. In Kotlin, you'd use a sealed class. TypeScript does it with plain objects and unions. No class hierarchy needed.

Literal Types and as const

In Java, if you want a type that can only be one of a few string values, you reach for an enum. In TypeScript, you often don't need to.

type OrderStatus = "pending" | "processing" | "shipped" | "delivered" | "cancelled";

function updateOrder(orderId: string, status: OrderStatus) {
  // ...
}

updateOrder("abc-123", "shipped");     // works
updateOrder("abc-123", "exploded");    // compile error
Enter fullscreen mode Exit fullscreen mode

That's it. No class, no enum declaration. Just a union of string literals. The compiler enforces it.

You can do the same with numbers:

type HttpSuccessCode = 200 | 201 | 204;
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
Enter fullscreen mode Exit fullscreen mode

Now, as const. This one confused me for a while. When you write:

const config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: 3,
};
// TypeScript infers: { apiUrl: string; timeout: number; retries: number }
Enter fullscreen mode Exit fullscreen mode

TypeScript widens the types. apiUrl is string, not "https://api.example.com". That's usually fine. But sometimes you want the literal types preserved:

const config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: 3,
} as const;
// Now it's: { readonly apiUrl: "https://api.example.com"; readonly timeout: 5000; readonly retries: 3 }
Enter fullscreen mode Exit fullscreen mode

as const does two things: makes everything readonly and preserves literal types. It's especially useful with arrays:

const ROLES = ["admin", "editor", "viewer"] as const;
// Type is: readonly ["admin", "editor", "viewer"]

type Role = (typeof ROLES)[number];
// Type is: "admin" | "editor" | "viewer"
Enter fullscreen mode Exit fullscreen mode

That (typeof ROLES)[number] syntax looks strange the first time. It's indexing the tuple type with number to extract the union of all element types. You get used to it.

Enums: The Controversial One

TypeScript has enums. I'm going to tell you to avoid them.

// TypeScript enum
enum Direction {
  Up,
  Down,
  Left,
  Right,
}
Enter fullscreen mode Exit fullscreen mode

This looks like a Java or C# enum. The problem is what happens at compile time. Remember how I said TypeScript types get erased at runtime? Enums are the exception. They emit actual JavaScript code:

// Compiled output
var Direction;
(function (Direction) {
  Direction[(Direction["Up"] = 0)] = "Up";
  Direction[(Direction["Down"] = 1)] = "Down";
  Direction[(Direction["Left"] = 2)] = "Left";
  Direction[(Direction["Right"] = 3)] = "Right";
})(Direction || (Direction = {}));
Enter fullscreen mode Exit fullscreen mode

That's a runtime object with bidirectional mapping. Direction.Up is 0, but Direction[0] is "Up". This creates subtle bugs. You can pass any number where a Direction is expected and the compiler won't complain:

function move(direction: Direction) { /* ... */ }
move(42); // No error! This compiles fine with numeric enums.
Enter fullscreen mode Exit fullscreen mode

String enums are slightly better:

enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT",
}
Enter fullscreen mode Exit fullscreen mode

At least now you can't pass arbitrary numbers. But you still get the runtime code emission, and you've created a nominal type that only accepts values from that specific enum, not matching string literals. move("UP") won't compile, even though the underlying value is "UP".

What I use instead:

const Direction = {
  Up: "UP",
  Down: "DOWN",
  Left: "LEFT",
  Right: "RIGHT",
} as const;

type Direction = (typeof Direction)[keyof typeof Direction];
// type Direction = "UP" | "DOWN" | "LEFT" | "RIGHT"
Enter fullscreen mode Exit fullscreen mode

Yes, the type and const have the same name. TypeScript allows this because types and values live in separate namespaces. You get:

  • A runtime object you can reference (Direction.Up)
  • A type you can use in annotations (direction: Direction)
  • No weird runtime code generation
  • Regular string literal unions under the hood

The two-line pattern looks a bit unusual at first. After a week you stop noticing.

Discriminated Unions — The Pattern That Replaces Inheritance

This is the single most important pattern in TypeScript. If you only take one thing from this post, make it this.

In Java, when you have a family of related types with different data, you reach for inheritance:

// Java approach
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}

// Then pattern matching (Java 21+)
double area(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t -> 0.5 * t.base() * t.height();
    };
}
Enter fullscreen mode Exit fullscreen mode

In TypeScript, you do this with a discriminated union. Each variant has a common property (the "discriminant") that tells you which variant you're dealing with:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      // TypeScript narrows: shape is { kind: "circle"; radius: number }
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      // TypeScript narrows: shape is { kind: "rectangle"; width: number; height: number }
      return shape.width * shape.height;
    case "triangle":
      // TypeScript narrows: shape is { kind: "triangle"; base: number; height: number }
      return 0.5 * shape.base * shape.height;
  }
}
Enter fullscreen mode Exit fullscreen mode

No classes. No new. No instanceof. Just plain objects with a tag field, and the compiler tracks which properties exist based on that tag.

A real-world example -- handling different types of payment events:

type PaymentEvent =
  | { type: "payment_initiated"; orderId: string; amount: number; currency: string }
  | { type: "payment_authorized"; orderId: string; authorizationCode: string }
  | { type: "payment_captured"; orderId: string; capturedAmount: number }
  | { type: "payment_failed"; orderId: string; reason: string; retryable: boolean };

function processPaymentEvent(event: PaymentEvent): void {
  switch (event.type) {
    case "payment_initiated":
      console.log(`Order ${event.orderId}: $${event.amount} ${event.currency}`);
      break;
    case "payment_authorized":
      console.log(`Order ${event.orderId}: authorized (${event.authorizationCode})`);
      break;
    case "payment_captured":
      console.log(`Order ${event.orderId}: captured $${event.capturedAmount}`);
      break;
    case "payment_failed":
      console.log(`Order ${event.orderId}: failed — ${event.reason}`);
      if (event.retryable) {
        // schedule retry
      }
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

Each case narrows the type, so event.authorizationCode is only accessible in the "payment_authorized" branch. Try to access it elsewhere and you get a compile error.

The best part: exhaustiveness checking. Add a new event type to the union and forget to handle it? The compiler will tell you.

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}

function processPaymentEvent(event: PaymentEvent): void {
  switch (event.type) {
    case "payment_initiated":
      // ...
      break;
    case "payment_authorized":
      // ...
      break;
    // Oops, forgot payment_captured and payment_failed
    default:
      assertNever(event);
      // Compile error! event is PaymentEvent, not never
  }
}
Enter fullscreen mode Exit fullscreen mode

If you handle all cases, event in the default branch is never (an impossible type, nothing can reach there). If you miss a case, the type isn't never and assertNever refuses to accept it. The compiler catches the gap.

This is TypeScript's answer to sealed classes and pattern matching. It's lighter-weight, it works with plain data, and it serializes/deserializes to JSON without any ceremony -- which matters a lot when you're building APIs.

I spent my first few months writing TypeScript classes with inheritance. Once I understood discriminated unions, I deleted most of them.

unknown vs any: Pick the Right Escape Hatch

Coming from PHP's mixed or Java's Object, you'll be tempted to reach for any when you don't know a type.

function processData(data: any) {
  // No errors anywhere. TypeScript stops checking.
  console.log(data.foo.bar.baz.whatever);
  data.nonExistentMethod();
  // All compiles fine. All blows up at runtime.
}
Enter fullscreen mode Exit fullscreen mode

any disables type checking. Not just for that variable, but for everything it touches. It's viral. If you pass an any value into a well-typed function, the return value often becomes any too.

unknown is the type-safe alternative:

function processData(data: unknown) {
  // console.log(data.foo); // Compile error! Can't access properties on unknown

  // You have to narrow first
  if (typeof data === "object" && data !== null && "foo" in data) {
    console.log(data.foo);
  }
}
Enter fullscreen mode Exit fullscreen mode

unknown means "I don't know what this is, but I'll check before I use it." That's the correct mental model for untyped external data: API responses, parsed JSON, user input, third-party library returns.

Here's a practical example with API calls:

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data: unknown = await response.json();

  // Validate the shape before trusting it
  if (
    typeof data === "object" &&
    data !== null &&
    "id" in data &&
    "name" in data &&
    "email" in data
  ) {
    return data as User;
  }

  throw new Error("Invalid user data from API");
}
Enter fullscreen mode Exit fullscreen mode

In practice, you'd use a validation library like Zod or Valibot instead of manual checks. We'll get into that later in the series. The point is: unknown forces you to validate, any lets you pretend everything is fine.

My rule: never use any in application code. If you see it in a code review, push back. The one exception is type assertions in test files where fighting the type system adds no value. Even then, unknown with a cast is usually better.

With strict: true (enabled by default when you run tsc --init in TypeScript 6), the compiler already forbids implicit any in most places. Lean into that. If you find yourself wanting to type something as any, it's a sign you need to think harder about what the actual type is.

What's Next

We've covered the core of TypeScript's type system: how unions, literal types, and discriminated unions replace patterns you'd normally build with class hierarchies and inheritance. There's more depth to each of these, especially once generics get involved.

That's exactly what Post 3 is about: functions and generics. How TypeScript's generic system compares to Java's (spoiler: it's more flexible and more confusing), type inference, generic constraints, and the patterns you'll actually use in backend code.


What's your take on enums vs as const? If you've worked in both TypeScript and a language like Java or C#, I'd like to hear which patterns you kept and which you dropped. Let me know in the comments.

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)