My project:
Me: , 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.
CODEconst name: string = "Gabriel";
const age: number = 31;
const isActive: boolean = true;
No
intvsfloatvsdouble. It's allnumber. There's alsobigintif you need arbitrary precision, butnumbercovers 99% of cases.
Arrays have two syntaxes:
CODEconst ids: number[] = [1, 2, 3];
const names: Array<string> = ["Alice", "Bob"];
Both do the same thing. I use
number[]because it's shorter. Some teams preferArray<string>for consistency with other generic types. Pick one, move on.
Object types look like this:
CODEconst user: { id: number; name: string; email: string } = {
id: 1,
name: "Gabriel",
email: "[email protected]",
};
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
typeandinterfacefor describing the shape of data, and they overlap a lot.
CODE// Type alias
type User = {
id: number;
name: string;
email: string;
};
// Interface
interface User {
id: number;
name: string;
email: string;
}
For object shapes, these are nearly interchangeable. Both support extending:
CODE// Type uses intersection
type AdminUser = User & {
permissions: string[];
};
// Interface uses extends
interface AdminUser extends User {
permissions: string[];
}
So which one do you pick?
Here's my take: use
typefor almost everything. Useinterfacewhen you specifically need declaration merging (where multipleinterfacedeclarations 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:
CODEinterface Window {
myCustomProperty: string;
}
// This merges with the existing Window interface
// rather than overwriting it
You can't do that with
type. But how often do you actually need that? Almost never in application code.typealiases are more flexible: they can represent unions, intersections, primitives, tuples. Interfaces can only describe object shapes.
CODEtype 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
The TypeScript team has gone back and forth on recommendations over the years. My rule:
typeby default,interfacewhen 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:
CODE// 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
TypeScript unions solve this directly:
CODEfunction parseInput(raw: string): number | string {
const parsed = Number(raw);
return Number.isNaN(parsed) ? raw : parsed;
}
const result = parseInput("42");
// result is number | string
The compiler tracks the union. You can narrow it with
typeof:
CODEif (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());
}
This is called type narrowing, and it's one of the most powerful features in the type system. No casts, no
instanceofchains with abstract base classes. The compiler follows your control flow and narrows the type automatically.
A more realistic version, modeling API responses:
CODEtype 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);
}
}
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 andas 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.
CODEtype OrderStatus = "pending" | "processing" | "shipped" | "delivered" | "cancelled";
function updateOrder(orderId: string, status: OrderStatus) {
// ...
}
updateOrder("abc-123", "shipped"); // works
updateOrder("abc-123", "exploded"); // compile error
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:
CODEtype HttpSuccessCode = 200 | 201 | 204;
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
Now,
as const. This one confused me for a while. When you write:
CODEconst config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
};
// TypeScript infers: { apiUrl: string; timeout: number; retries: number }
TypeScript widens the types.
apiUrlisstring, not"https://api.example.com". That's usually fine. But sometimes you want the literal types preserved:
CODEconst 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 }
as constdoes two things: makes everythingreadonlyand preserves literal types. It's especially useful with arrays:
CODEconst ROLES = ["admin", "editor", "viewer"] as const;
// Type is: readonly ["admin", "editor", "viewer"]
type Role = (typeof ROLES)[number];
// Type is: "admin" | "editor" | "viewer"
That
(typeof ROLES)[number]syntax looks strange the first time. It's indexing the tuple type withnumberto 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.
CODE// TypeScript enum
enum Direction {
Up,
Down,
Left,
Right,
}
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:
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 = {}));
That's a runtime object with bidirectional mapping.
Direction.Upis0, butDirection[0]is"Up". This creates subtle bugs. You can pass any number where aDirectionis expected and the compiler won't complain:
CODEfunction move(direction: Direction) { /* ... */ }
move(42); // No error! This compiles fine with numeric enums.
String enums are slightly better:
CODEenum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT",
}
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:
CODEconst Direction = {
Up: "UP",
Down: "DOWN",
Left: "LEFT",
Right: "RIGHT",
} as const;
type Direction = (typeof Direction)[keyof typeof Direction];
// type Direction = "UP" | "DOWN" | "LEFT" | "RIGHT"
Yes, the
typeandconsthave 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:
CODE// 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();
};
}
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:
CODEtype 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;
}
}
No classes. No
new. Noinstanceof. 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:
CODEtype 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;
}
}
Each case narrows the type, so
event.authorizationCodeis 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.
CODEfunction 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
}
}
If you handle all cases,
eventin thedefaultbranch isnever(an impossible type, nothing can reach there). If you miss a case, the type isn'tneverandassertNeverrefuses 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.
unknownvsany: Pick the Right Escape Hatch
Coming from PHP's
mixedor Java'sObject, you'll be tempted to reach foranywhen you don't know a type.
CODEfunction 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.
}
anydisables type checking. Not just for that variable, but for everything it touches. It's viral. If you pass ananyvalue into a well-typed function, the return value often becomesanytoo.
unknownis the type-safe alternative:
CODEfunction 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);
}
}
unknownmeans "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:
CODEasync 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");
}
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:
unknownforces you to validate,anylets you pretend everything is fine.
My rule: never use
anyin 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,unknownwith a cast is usually better.
With
strict: true(enabled by default when you runtsc --initin TypeScript 6), the compiler already forbids implicitanyin most places. Lean into that. If you find yourself wanting to type something asany, 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 . A star helps a lot. You can follow my work at gabrielanhaia.
↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.- A runtime object you can reference (
The Type System: What You Know, What's New, and What's Weird
- ▸ Primitives, Arrays, Objects: The Familiar Stuff
- ▸ Type Aliases vs Interfaces
- ▸ Union Types: This Changes Everything
- ▸ Literal Types and as const
- ▸ Enums: The Controversial One
- ▸ Discriminated Unions — The Pattern That Replaces Inheritance
- ▸ unknown vs any: Pick the Right Escape Hatch
- ▸ What's Next
SOCIAL SHARE CARD GENERATOR