The Type System: What You Know, What's New, and What's Weird
My project: Hermes IDE | GitHub Me: gabrielanhaia 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 ;
-
My project: Hermes IDE | GitHub
-
Me: gabrielanhaia
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;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 = ["Alice", "Bob"];const ids: number[] = [1, 2, 3]; const names: Array = ["Alice", "Bob"];Enter fullscreen mode
Exit fullscreen mode
Both do the same thing. I use number[] because it's shorter. Some teams prefer Array 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: "[email protected]", };const user: { id: number; name: string; email: string } = { id: 1, name: "Gabriel", email: "[email protected]", };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; };// 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[]; };// 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 itinterface Window { myCustomProperty: string; } // This merges with the existing Window interface // rather than overwriting itEnter 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 thistype 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 thisEnter 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// 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 everywhereEnter 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; }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()); }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 = | { success: true; data: T } | { success: false; error: string };type ApiResponse = | { success: true; data: T } | { success: false; error: string };function handleResponse(response: ApiResponse) { 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 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;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 }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 }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"]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, }// 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 = {}));// 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.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", }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;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 {}// 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 };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 };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(function assertNever(value: never): never { throw new Error(); }); }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. }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 unknownfunction 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 { const response = await fetch(async function fetchUser(id: string): Promise { const response = await fetch(); const data: unknown = await response.json();); 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.
DEV Community
https://dev.to/gabrielanhaia/the-type-system-what-you-know-whats-new-and-whats-weird-3ojdSign in to highlight and annotate this article

Conversation starters
Daily AI Digest
Get the top 5 AI stories delivered to your inbox every morning.
More about
modelversionupdate
Multi-Objective Agentic Rewrites for Unstructured Data Processing
arXiv:2512.02289v4 Announce Type: replace Abstract: One year ago, we open-sourced DocETL, a declarative system for LLM-powered data processing that, as of March 2026, has 3.7K GitHub stars and users across domains (e.g., journalism, law, medicine, policy, finance, and urban planning). In DocETL, users build pipelines by composing operators described in natural language, also known as semantic operators, with an LLM executing each operator's logic. However, due to complexity in the operator or the data it operates on, LLMs often give inaccurate results. To address this challenge, DocETL introduced rewrite directives, or abstract rules that guide LLM agents in rewriting pipelines by decomposing operators or data. For example, decomposing a single filter("is this email sent from an executive

Adaptive Fully Dynamic $k$-Center Clustering with (Near-)Optimal Worst-Case Guarantees
arXiv:2604.01726v1 Announce Type: new Abstract: Given a sequence of adversarial point insertions and point deletions, is it possible to simultaneously optimize the approximation ratio, update time, and recourse for a $k$-clustering problem? If so, can this be achieved with worst-case guarantees against an adaptive adversary? These questions have garnered significant attention in recent years. Prior works by Bhattacharya, Costa, Garg, Lattanzi, and Parotsidis [FOCS '24] and by Bhattacharya, Costa, and Farokhnejad [STOC '25] have taken significant steps toward this direction for the $k$-median clustering problem and its generalization, the $(k, z)$-clustering problem. In this paper, we study the $k$-center clustering problem, which is one of the most classical and well-studied $k$-clustering
Knowledge Map
Connected Articles — Knowledge Graph
This article is connected to other articles through shared AI topics and tags.
More in Releases

DGAI: Decoupled On-Disk Graph-Based ANN Index for Efficient Updates and Queries
arXiv:2510.25401v3 Announce Type: replace Abstract: On-disk graph-based indexes are favored for billion-scale Approximate Nearest Neighbor Search (ANNS) due to their high performance and cost-efficiency. However, existing systems typically rely on a coupled storage architecture that co-locates vectors and graph topology, which introduces substantial redundant I/O during index updates, thereby degrading usability in dynamic workloads. In this paper, we propose a decoupled storage architecture that physically separates heavy vectors from the lightweight graph topology. This design substantially improves update performance by reducing redundant I/O during updates. However, it introduces I/O amplification during ANNS, leading to degraded query efficiency.To improve query performance within the

A Simple Average-case Analysis of Recursive Randomized Greedy MIS
arXiv:2604.01462v1 Announce Type: new Abstract: We revisit the complexity analysis of the recursive version of the randomized greedy algorithm for computing a maximal independent set (MIS), originally analyzed by Yoshida, Yamamoto, and Ito (2009). They showed that, on average per vertex, the expected number of recursive calls made by this algorithm is upper bounded by the average degree of the input graph. While their analysis is clever and intricate, we provide a significantly simpler alternative that achieves the same guarantee. Our analysis is inspired by the recent work of Dalirrooyfard, Makarychev, and Mitrovi\'c (2024), who developed a potential-function-based argument to analyze a new algorithm for correlation clustering. We adapt this approach to the MIS setting, yielding a more di

Space-Efficient Text Indexing with Mismatches using Function Inversion
arXiv:2604.01307v1 Announce Type: new Abstract: A classic data structure problem is to preprocess a string T of length $n$ so that, given a query $q$, we can quickly find all substrings of T with Hamming distance at most $k$ from the query string. Variants of this problem have seen significant research both in theory and in practice. For a wide parameter range, the best worst-case bounds are achieved by the "CGL tree" (Cole, Gottlieb, Lewenstein 2004), which achieves query time roughly $\tilde{O}(|q| + \log^k n + \# occ)$ where $\# occ$ is the size of the output, and space ${O}(n\log^k n)$. The CGL Tree space was recently improved to $O(n \log^{k-1} n)$ (Kociumaka, Radoszewski 2026). A natural question is whether a high space bound is necessary. How efficient can we make queries when the d

HippoMM: Hippocampal-inspired Multimodal Memory for Long Audiovisual Event Understanding
arXiv:2504.10739v2 Announce Type: replace-cross Abstract: Comprehending extended audiovisual experiences remains challenging for computational systems, particularly temporal integration and cross-modal associations fundamental to human episodic memory. We introduce HippoMM, a computational cognitive architecture that maps hippocampal mechanisms to solve these challenges. Rather than relying on scaling or architectural sophistication, HippoMM implements three integrated components: (i) Episodic Segmentation detects audiovisual input changes to split videos into discrete episodes, mirroring dentate gyrus pattern separation; (ii) Memory Consolidation compresses episodes into summaries with key features preserved, analogous to hippocampal memory formation; and (iii) Hierarchical Memory Retriev


Discussion
Sign in to join the discussion
No comments yet — be the first to share your thoughts!