- My project: Hermes IDE | GitHub
- Me: gabrielanhaia
Java's type system stops at generics and bounded wildcards. TypeScript's type system is basically a programming language itself. Here's the useful part without the type gymnastics.
Posts 1-3 covered structural typing, unions, generics, type narrowing, satisfies, and overloads. This post is where TypeScript really pulls away -- nothing in Java, PHP, or C# maps directly to what we're about to cover.
Don't panic. You only need about 8-10 of these utility types for daily work. The rest are for library authors and people who enjoy solving puzzles on weekends. I'll be clear about where that line is.
The Utility Types That Actually Matter
TypeScript ships with a set of built-in generic types that transform other types. Think of them as functions, but for types instead of values. You pass a type in, you get a modified type back. Java has nothing like this. The closest thing would be annotation processing, but that's a build step, not a type-level operation.
Partial<T> and Required<T> -- Two Sides of One Coin
These two are the ones you'll reach for first. Partial<T> takes every property in T and makes it optional. Required<T> does the reverse.
The classic use case is update functions. You have a User type where every field is required, but your updateUser function should accept any subset of fields:
interface User {
id: string;
email: string;
name: string;
role: "admin" | "member" | "viewer";
lastLoginAt: Date;
}
// Without Partial, you'd have to define a separate UpdateUserPayload interface
function updateUser(id: string, changes: Partial<User>): User {
// changes.email might be undefined, changes.name might be undefined, etc.
// Only the fields the caller provides will be present
return { ...existingUser, ...changes };
}
// Caller only sends what changed
updateUser("usr_123", { name: "Gabriel Anhaia" });
updateUser("usr_123", { role: "admin", email: "new@email.com" });
In Java, you'd either create a separate DTO class with all nullable fields, use a builder pattern, or pass a Map<String, Object> and lose all type safety. I've done all three. They're all worse than this.
Required<T> is less common but shows up when you have a config object with optional defaults and need to guarantee everything's been filled in after merging:
interface AppConfig {
port?: number;
host?: string;
logLevel?: "debug" | "info" | "warn" | "error";
}
function initServer(userConfig: AppConfig): Required<AppConfig> {
const defaults: Required<AppConfig> = {
port: 3000,
host: "localhost",
logLevel: "info",
};
return { ...defaults, ...userConfig };
}
// Return type guarantees all fields are defined -- no more optional chaining
const config = initServer({ port: 8080 });
console.log(config.logLevel); // TypeScript knows this is defined, not "debug" | "info" | "warn" | "error" | undefined
Pick<T, Keys> and Omit<T, Keys> -- Carving Types Up
These two let you create new types by selecting or excluding specific properties. If you've ever written a DTO class in Java that's just 5 of the 15 fields from your entity, you'll appreciate this.
Pick grabs only the keys you specify:
interface Product {
id: string;
name: string;
description: string;
price: number;
costPrice: number;
sku: string;
inventoryCount: number;
createdAt: Date;
updatedAt: Date;
}
// For the product listing page, we only need a few fields
type ProductSummary = Pick<Product, "id" | "name" | "price">;
// That's equivalent to writing:
// interface ProductSummary { id: string; name: string; price: number; }
Omit does the opposite. It's what I reach for when building API response types where I need to strip sensitive or internal fields:
// Strip internal pricing data before sending to the frontend
type PublicProduct = Omit<Product, "costPrice" | "inventoryCount">;
// Or remove database timestamps for a creation payload
type CreateProductPayload = Omit<Product, "id" | "createdAt" | "updatedAt">;
You can combine them too. Need an update payload that excludes the ID and timestamps, and makes everything optional?
type UpdateProductPayload = Partial<Omit<Product, "id" | "createdAt" | "updatedAt">>;
One line. In Java, that's a whole new class file. In Kotlin, it's a separate data class. Here it's a type alias composed from two utility types.
Record<K, V> -- Typed Dictionaries
When Java developers reach for Map<String, Something>, TypeScript developers should reach for Record. It creates an object type where every key of type K maps to a value of type V.
// A lookup table for order status display info
type OrderStatus = "pending" | "processing" | "shipped" | "delivered" | "cancelled";
const statusConfig: Record<OrderStatus, { label: string; color: string }> = {
pending: { label: "Pending", color: "#f59e0b" },
processing: { label: "Processing", color: "#3b82f6" },
shipped: { label: "Shipped", color: "#8b5cf6" },
delivered: { label: "Delivered", color: "#10b981" },
cancelled: { label: "Cancelled", color: "#ef4444" },
};
The real win here is exhaustiveness. If you add a new status to the union but forget to add it to the Record, TypeScript catches it at compile time. Java's Map can't do that. You'd find out at runtime when some status falls through to a default case.
Record<string, unknown> is also your go-to replacement for any when you want to say "some object, but I don't know the shape yet."
Readonly<T> -- Immutability Without the Ceremony
Java has final for variables and Collections.unmodifiableList() for collections, but nothing that makes an entire object shape immutable at the type level. TypeScript does:
interface OrderLine {
productId: string;
quantity: number;
unitPrice: number;
}
function calculateTotal(lines: readonly OrderLine[]): number {
// lines.push(...) -- TypeScript error! Array is readonly.
// lines[0].quantity = 5 -- this still compiles! readonly on the array doesn't make elements immutable.
return lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
}
// If you want immutable elements too, apply Readonly to each one:
function calculateTotalStrict(lines: readonly Readonly<OrderLine>[]): number {
// lines.push(...) -- error
// lines[0].quantity = 5 -- also an error now
return lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
}
Worth remembering: Readonly is compile-time only. Nothing stops the JavaScript runtime from mutating the object. It's a signal to other developers and a safety net from the compiler, not a runtime guarantee.
ReturnType<T> and Awaited<T> -- Extracting Types from Code
These two are different from the others. Instead of transforming object shapes, they extract type information from function signatures and promises.
ReturnType grabs the return type of a function. This is useful when you're working with a third-party library and don't want to import (or can't find) the actual type name:
function createOrder(items: CartItem[], customer: Customer) {
return {
id: generateId(),
items,
customer,
total: items.reduce((sum, i) => sum + i.price * i.quantity, 0),
status: "pending" as const,
createdAt: new Date(),
};
}
// Don't manually recreate this type -- just extract it
type Order = ReturnType<typeof createOrder>;
Notice the typeof before the function name. ReturnType expects a function type, not a function value. This trips up every backend developer the first time. typeof createOrder gives you the function's type signature; ReturnType then pulls out just the return part.
Awaited unwraps a Promise to get the resolved value type. Before TypeScript 4.5, you had to write custom types for this. Now it's built in:
async function fetchUserProfile(userId: string) {
const response = await fetch(`/api/users/${userId}`);
return response.json() as Promise<{ id: string; name: string; avatar: string }>;
}
// Awaited<ReturnType<typeof fetchUserProfile>> gives us { id: string; name: string; avatar: string }
type UserProfile = Awaited<ReturnType<typeof fetchUserProfile>>;
It even handles nested promises. Awaited<Promise<Promise<string>>> resolves all the way down to string.
Mapped Types: Loops at the Type Level
All those utility types above? They're built from two lower-level features: mapped types and conditional types. You don't need to use these directly most of the time, but understanding them explains how everything else works.
A mapped type iterates over the keys of another type and produces a new type. The syntax looks like a computed property in a for...in loop:
// This is roughly what Partial<T> looks like under the hood
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
Read it as: "For every key K in the keys of T, create an optional property with the same value type." The ? modifier makes each property optional. Remove it and you've got a type-level identity function. Replace T[K] with something else and you're transforming values.
Here's a practical example. Say you want a type where every field of an object becomes a boolean, useful for tracking which fields in a form have been modified:
type DirtyFields<T> = {
[K in keyof T]: boolean;
};
interface OrderForm {
customerName: string;
shippingAddress: string;
billingAddress: string;
notes: string;
}
// { customerName: boolean; shippingAddress: boolean; billingAddress: boolean; notes: boolean }
type OrderFormDirtyState = DirtyFields<OrderForm>;
const dirtyState: OrderFormDirtyState = {
customerName: true,
shippingAddress: false,
billingAddress: false,
notes: true,
};
The closest thing in Java is annotation processing, where you generate source files at compile time based on annotations. But that requires a separate build step, a processor class, and writing actual Java code that outputs Java code. Mapped types do it inline, in the type system itself, with zero runtime cost.
You can also filter keys during mapping. Want a type that only includes the string properties of an object?
type StringFieldsOnly<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
// From Product, this extracts only: { id: string; name: string; description: string; sku: string }
type ProductStringFields = StringFieldsOnly<Product>;
That as clause is called a key remapping. The never type acts like a filter -- if the condition fails, the key is dropped entirely. It's powerful, but we're already getting close to the "maybe too clever" territory. More on that later.
Conditional Types: If/Else for Types
Conditional types follow the pattern T extends U ? X : Y. If T is assignable to U, the type resolves to X. Otherwise, it resolves to Y. It's a ternary operator, but for types.
The simplest example is a type that checks whether something is an array and extracts the element type:
type ElementOf<T> = T extends Array<infer U> ? U : never;
type A = ElementOf<string[]>; // string
type B = ElementOf<number[]>; // number
type C = ElementOf<string>; // never -- not an array
That infer U is doing pattern matching. It says "if T is an Array of something, capture that something as U." We'll dig deeper into infer in a moment.
Here's a more practical use. You have an API that can return either a single item or a list, and you want a helper type that always gives you the item type:
interface ApiResponse<T> {
data: T;
meta: { requestId: string; timestamp: number };
}
type UnwrapResponse<T> = T extends ApiResponse<infer D> ? D : never;
type UserData = UnwrapResponse<ApiResponse<User>>; // User
type OrderData = UnwrapResponse<ApiResponse<Order[]>>; // Order[]
Conditional types also distribute over unions, which is both useful and confusing. If you pass a union type in, the conditional is applied to each member separately:
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>; // string[] | number[]
// NOT (string | number)[] -- each union member gets wrapped individually
This distributive behavior is why Exclude<T, U> and Extract<T, U> work:
// Built-in Exclude: removes members of a union
type WithoutAdmin = Exclude<"admin" | "member" | "viewer", "admin">; // "member" | "viewer"
// Built-in Extract: keeps only matching members
type OnlyAdmin = Extract<"admin" | "member" | "viewer", "admin">; // "admin"
Template Literal Types: Typed String Patterns
TypeScript can enforce patterns on string values at the type level. I didn't believe it until I tried it.
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type ApiVersion = "v1" | "v2";
// Every valid route must match this pattern
type ApiRoute = `/${ApiVersion}/users/${string}`;
const valid: ApiRoute = "/v1/users/123"; // fine
const alsoValid: ApiRoute = "/v2/users/abc-def"; // fine
// const invalid: ApiRoute = "/v3/users/123"; // error: Type '"/v3/users/123"' is not assignable
You can combine template literals with union types and they expand into every combination:
type EventName = `${"user" | "order" | "product"}.${"created" | "updated" | "deleted"}`;
// Expands to: "user.created" | "user.updated" | "user.deleted" | "order.created" | ... (9 total)
function on(event: EventName, handler: () => void) {
// register handler
}
on("user.created", () => {}); // fine
on("user.removed", () => {}); // error -- "removed" isn't in the union
This is genuinely useful for typing event systems, route handlers, or any API where strings follow a convention. I've used it for typing Redis key patterns:
type CacheKey =
| `user:${string}:profile`
| `user:${string}:settings`
| `order:${string}:status`
| `session:${string}`;
function getCached(key: CacheKey): Promise<string | null> {
return redis.get(key);
}
getCached("user:usr_123:profile"); // fine
getCached("usr:123:profile"); // error -- wrong prefix
In Java, you'd validate these patterns at runtime with regex or a custom validator. TypeScript catches the mistake before you even run the code.
The infer Keyword: Pattern Matching on Types
We already saw infer briefly with ElementOf. It's TypeScript's way of saying "I don't know this type yet, but capture it so I can use it." It only works inside conditional types.
Think of infer as a regex capture group but for types. You describe a shape, mark the part you want to extract, and TypeScript fills it in.
Extracting the return type of a function (this is literally how ReturnType works internally):
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
Extracting the resolved type of a promise:
type MyAwaited<T> = T extends Promise<infer U> ? U : T;
Extracting the parameter types of a function:
type FirstParam<T> = T extends (first: infer P, ...rest: any[]) => any ? P : never;
function processOrder(order: Order, options: { notify: boolean }): void { /* ... */ }
type OrderParam = FirstParam<typeof processOrder>; // Order
You can even use infer with template literal types to parse strings at the type level:
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractRouteParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"
That last example is also where I want to draw a clear line.
When You're Overengineering
If your type definition takes more than 3 lines to read and understand, it's probably too complex for application code. Library code is different -- framework authors need this stuff. But in your average backend service? Keep it simple.
Here's a real example of what I mean. I've seen code like this in production:
// Don't do this in application code
type DeepReadonly<T> = T extends (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
type DeepRequired<T> = T extends object
? { [K in keyof T]-?: DeepRequired<T[K]> }
: T;
// And then combining them...
type ApiUpdatePayload<T> = DeepPartial<Omit<DeepReadonly<T>, "id" | "createdAt">>;
The person who wrote this felt clever. The person who maintains it does not. Compare that to:
// Do this instead
interface UpdateProductPayload {
name?: string;
description?: string;
price?: number;
sku?: string;
}
Yes, the explicit interface has some duplication with the Product type. But any developer can read it instantly. They don't need to mentally evaluate three levels of recursive generic types to understand what fields are accepted.
My rule of thumb: use utility types when they make intent clearer (Partial<User> is obvious), but write explicit types when the composition gets nested or recursive. The TypeScript compiler doesn't care about elegance. Your teammates care about readability.
Some practical guidelines I follow:
Use utility types freely: Partial<T>, Pick<T, K>, Omit<T, K>, Record<K, V>, Readonly<T>. These are readable to anyone who knows basic TypeScript.
Use with caution: Conditional types, mapped types with key remapping, multiple nested utility types. Fine for shared library code with good documentation.
Avoid in application code: Recursive conditional types, infer chains more than one level deep, type-level string parsing. If you need these, you're probably building a framework -- and that's fine, but know the difference.
What's Coming in Post 5
We've covered the type-level toolkit. You now know more about TypeScript's type system than most people who've been writing it for years. But we haven't talked about how to actually organize a TypeScript project.
Post 5 is about modules, imports, and project structure. How import/export works (it's not like Java packages), what tsconfig.json actually controls, path aliases, barrel files, and the ESM vs. CommonJS situation that still confuses everyone in 2026. The stuff that determines whether your project is pleasant or painful to work in.
What utility type do you reach for most? Or if you're coming from Java/C#/PHP, which one do you wish your language had? Drop a comment -- I'm curious what resonates.
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)