The Only TypeScript Article You'd Ever Need - 3/4
Welcome to Type Wizardry: Where TypeScript Gets Actually Interesting
You’ve survived the basics. You can write functions with proper types, build classes that don’t leak implementation details, and wield generics like a semi-competent developer. Good for you. But here’s the thing: you’re still just playing with building blocks.
What if I told you that TypeScript isn’t just about adding type annotations to JavaScript? What if the real power lies in making types that compute, combine, and transform based on other types? What if you could write type-level logic that prevents entire categories of bugs before they even have a chance to exist?
Welcome to advanced TypeScript, where types become programmable logic and your compiler becomes your most trusted code reviewer. This is where you stop being someone who uses TypeScript and start being someone who thinks in types.
Ready to bend the type system to your will? Let’s get dangerous.
Union and Intersection Types: The Art of Type Combination
Think of types like LEGO pieces. Basic types are your standard rectangular blocks. But what if you need something more complex? What if you need a piece that could be either a red block or a blue block? Or a piece that’s both a block and a wheel?
That’s where union and intersection types come in. They’re not just syntax; they’re the foundation of expressing complex, real-world relationships in your code.
Union Types (|
): The “Either/Or” of Type Safety
A union type says “this value can be one of several different types.” It’s like having a function parameter that could accept multiple forms of input without losing type safety.
// Instead of this mess:
function processId(id: any) {
if (typeof id === "string") {
return id.toUpperCase();
} else {
return id.toString();
}
}
// Write this:
function processId(id: string | number): string {
if (typeof id === "string") {
return id.toUpperCase(); // TypeScript knows it's a string here
} else {
return id.toString(); // TypeScript knows it's a number here
}
}
processId("abc-123"); // ✅ Works
processId(42); // ✅ Works
// processId(true); // ❌ TypeScript catches this
But unions really shine when modeling real-world data that comes in different shapes:
interface LoadingState {
status: "loading";
}
interface SuccessState {
status: "success";
data: User[];
}
interface ErrorState {
status: "error";
message: string;
code: number;
}
// Your API response can be exactly one of these states
type ApiState = LoadingState | SuccessState | ErrorState;
function handleApiResponse(state: ApiState) {
switch (state.status) {
case "loading":
showSpinner();
break;
case "success":
// TypeScript knows state.data exists and is User[]
displayUsers(state.data);
break;
case "error":
// TypeScript knows state.message and state.code exist
showError(`Error ${state.code}: ${state.message}`);
break;
default:
// TypeScript will complain if you add a new state but forget to handle it here
const exhaustive: never = state;
throw new Error(`Unhandled state: ${exhaustive}`);
}
}
Notice what happened there? By using union types with discriminating properties (status
), TypeScript automatically narrows the type in each branch. No more guessing what properties exist. No more runtime explosions because you tried to access data
on an error state.
Intersection Types (&
): The “Both/And” of Type Composition
While unions say “this OR that,” intersections say “this AND that.” They combine multiple types into a single type that has all the properties of its constituent parts.
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface Identifiable {
id: string;
}
interface User {
name: string;
email: string;
}
// A database entity is a User AND has timestamps AND has an ID
type DatabaseUser = User & Timestamped & Identifiable;
const dbUser: DatabaseUser = {
id: "user-123",
name: "Alice",
email: "alice@example.com",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-06-01"),
}; // Must have ALL properties from all three types
Intersections are particularly powerful for creating configurable interfaces:
interface BaseConfig {
apiUrl: string;
timeout: number;
}
interface DatabaseConfig {
host: string;
port: number;
database: string;
}
interface CacheConfig {
redis: {
host: string;
port: number;
};
}
// A production config needs all of these
type ProductionConfig = BaseConfig & DatabaseConfig & CacheConfig;
// A development config might only need the basics
type DevelopmentConfig = BaseConfig & Partial<DatabaseConfig>;
Literal Types and Enums: Stop Using Magic Strings (Please)
You know what’s worse than a runtime error? A runtime error caused by a typo in a string that could have been caught at compile time. If you’re still using raw strings for values that have specific meaning, you’re essentially writing bugs with extra steps.
Literal Types: Precision Over Flexibility
Literal types let you specify exact values, not just general types:
// Instead of allowing any string:
function setAlignment(align: string) {
// Hope the caller passes "left", "center", or "right"
// Spoiler alert: they'll pass "centre" and wonder why it breaks
}
// Be specific about what you accept:
function setAlignment(align: "left" | "center" | "right") {
// TypeScript ensures only valid alignments
}
setAlignment("left"); // ✅ Works
setAlignment("center"); // ✅ Works
// setAlignment("centre"); // ❌ TypeScript catches British spelling mistakes
You can combine literal types with unions to create powerful, self-documenting APIs:
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type StatusCode = 200 | 201 | 400 | 401 | 403 | 404 | 500;
interface ApiRequest {
method: HttpMethod;
endpoint: string;
expectedStatus: StatusCode;
}
function makeRequest(request: ApiRequest) {
// TypeScript ensures method and expectedStatus are valid
// No more wondering if someone typo'd "PSOT" instead of "POST"
}
Enums: When You Need More Structure
For related constants, enums provide better organization and can carry additional metadata:
enum UserRole {
ADMIN = "admin",
MODERATOR = "moderator",
USER = "user",
GUEST = "guest",
}
enum HttpStatusCode {
OK = 200,
CREATED = 201,
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
NOT_FOUND = 404,
INTERNAL_SERVER_ERROR = 500,
}
// Now your code is self-documenting
function checkPermission(userRole: UserRole): boolean {
return userRole === UserRole.ADMIN || userRole === UserRole.MODERATOR;
}
function handleApiResponse(statusCode: HttpStatusCode) {
if (statusCode === HttpStatusCode.OK) {
// Handle success
} else if (statusCode >= HttpStatusCode.BAD_REQUEST) {
// Handle client/server errors
}
}
Pro tip: Use string enums for values you’ll debug (UserRole.ADMIN
shows up as "admin"
in logs), and numeric enums when you need bitwise operations or performance is critical.
Type Guards: Teaching TypeScript About Runtime Reality
TypeScript’s type system works at compile time, but your data comes from the runtime world of APIs, user input, and cosmic rays flipping bits in your RAM. Type guards are how you bridge that gap safely.
Built-in Type Guards: The Classics
The typeof
, instanceof
, and in
operators become type guards when used in conditional statements:
function processValue(value: string | number | Date) {
if (typeof value === "string") {
// TypeScript knows value is string
return value.trim().toUpperCase();
}
if (typeof value === "number") {
// TypeScript knows value is number
return value.toFixed(2);
}
if (value instanceof Date) {
// TypeScript knows value is Date
return value.toISOString();
}
// TypeScript knows this is unreachable
const exhaustive: never = value;
throw new Error(`Unexpected value: ${exhaustive}`);
}
For object types, use the in
operator:
interface Bird {
fly(): void;
wingspan: number;
}
interface Fish {
swim(): void;
finCount: number;
}
function moveAnimal(animal: Bird | Fish) {
if ("fly" in animal) {
// TypeScript knows it's a Bird
animal.fly();
console.log(`Wingspan: ${animal.wingspan}`);
} else {
// TypeScript knows it's a Fish
animal.swim();
console.log(`Fins: ${animal.finCount}`);
}
}
Custom Type Guards: When Built-ins Aren’t Enough
Sometimes you need more sophisticated runtime checks. Custom type guards use the is
keyword to teach TypeScript about your validation logic:
interface ValidUser {
id: string;
email: string;
name: string;
}
// A type predicate function
function isValidUser(data: unknown): data is ValidUser {
return (
typeof data === "object" &&
data !== null &&
typeof (data as any).id === "string" &&
typeof (data as any).email === "string" &&
typeof (data as any).name === "string" &&
(data as any).email.includes("@")
);
}
// Now you can safely handle uncertain data
function handleApiData(data: unknown) {
if (isValidUser(data)) {
// TypeScript knows data is ValidUser
console.log(`Welcome, ${data.name}!`);
sendEmail(data.email);
} else {
throw new Error("Invalid user data");
}
}
// Parse JSON safely
try {
const userData = JSON.parse(someJsonString);
handleApiData(userData); // userData is unknown, but that's okay
} catch (error) {
console.error("Failed to parse user data");
}
This pattern is essential for dealing with external data. Never trust, always verify.
Type Assertions: The “Trust Me, Bro” Operator
Sometimes TypeScript’s type inference isn’t sophisticated enough to understand what you know to be true. Type assertions (as
) let you override the compiler’s judgment, but with great power comes great responsibility (to not shoot yourself in the foot).
Safe Type Assertions
Use assertions when you have information the compiler lacks:
// DOM manipulation - TypeScript can't know the specific element type
const canvas = document.getElementById("game-canvas") as HTMLCanvasElement;
const context = canvas.getContext("2d"); // Now TypeScript knows canvas has canvas-specific methods
// After validation with a type guard
function processFormData(formData: FormData) {
const email = formData.get("email") as string; // FormData.get returns string | File | null
// Only do this if you've validated the form beforehand!
}
// When working with libraries that have incomplete types
const config = JSON.parse(configString) as AppConfig;
// Better: use a type guard to validate first
Dangerous Type Assertions (Don’t Do These)
// Lying to the compiler
const userAge = "25" as number; // Runtime disaster waiting to happen
// userAge.toFixed(2); // "25".toFixed is not a function
// Asserting complex types without validation
const apiResponse = someUnknownData as ComplexUserProfile;
// If apiResponse is missing required properties, you're in trouble
// Using assertions to silence errors instead of fixing them
const result = riskyOperation() as SuccessResult;
// If riskyOperation() can fail, this assertion is wishful thinking
Rule of thumb: If you need to assert a type, first ask yourself if you should be using a type guard instead. Assertions should be rare and used only when you have information that TypeScript cannot infer.
Advanced Type Sorcery: Where Things Get Interesting
Congratulations, you’ve mastered the fundamentals of TypeScript’s type system. Now let’s talk about the really fun stuff: types that compute other types.
keyof
: X-Ray Vision for Object Types
The keyof
operator extracts the keys of an object type as a literal union:
interface ApiEndpoints {
users: "/api/users";
posts: "/api/posts";
comments: "/api/comments";
}
type EndpointNames = keyof ApiEndpoints; // "users" | "posts" | "comments"
// Use this for type-safe property access
function getEndpoint<T extends keyof ApiEndpoints>(name: T): ApiEndpoints[T] {
const endpoints: ApiEndpoints = {
users: "/api/users",
posts: "/api/posts",
comments: "/api/comments",
};
return endpoints[name];
}
const usersUrl = getEndpoint("users"); // Type is "/api/users"
// const invalid = getEndpoint("profiles"); // Error: not a valid key
Mapped Types: Type-Level Loops
Mapped types let you transform existing types by iterating over their properties:
// Make all properties optional (this is how Partial<T> works)
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// Make all properties readonly
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
// Transform all properties to strings (for form data)
type Stringify<T> = {
[K in keyof T]: string;
};
interface User {
id: number;
name: string;
isActive: boolean;
}
type UserForm = Stringify<User>;
// Result: { id: string; name: string; isActive: string; }
You can even add or remove modifiers:
// Remove readonly modifiers
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
// Remove optional modifiers (make everything required)
type Required<T> = {
[K in keyof T]-?: T[K];
};
Conditional Types: Type-Level If Statements
The most brain-bending feature of TypeScript’s type system. Conditional types let you write logic that executes at the type level:
// Basic conditional type
type IsString<T> = T extends string ? "yes" : "no";
type Test1 = IsString<"hello">; // "yes"
type Test2 = IsString<42>; // "no"
// Extract function return types
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser(id: string): { name: string; email: string } {
return { name: "Alice", email: "alice@example.com" };
}
type UserType = ReturnType<typeof getUser>; // { name: string; email: string }
The infer
keyword is particularly powerful for extracting types from complex type structures:
// Extract the element type from an array
type ArrayElement<T> = T extends (infer U)[] ? U : never;
type StringArray = string[];
type ElementType = ArrayElement<StringArray>; // string
// Extract promise resolved types
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type AsyncUserType = UnwrapPromise<Promise<User>>; // User
type SyncUserType = UnwrapPromise<User>; // User (no change)
Built-in Utility Types: The Standard Library of Type Magic
TypeScript comes with a rich set of utility types built from the primitives we’ve discussed. Understanding how they work internally makes you a more effective TypeScript developer.
The Essential Utilities
Partial<T>
- Makes all properties optional:
interface UserConfig {
theme: "dark" | "light";
notifications: boolean;
language: string;
}
function updateUserConfig(config: UserConfig, updates: Partial<UserConfig>) {
return { ...config, ...updates };
}
updateUserConfig(currentConfig, { theme: "dark" }); // Only updating theme
Required<T>
- Makes all properties required:
interface OptionalUser {
name?: string;
email?: string;
age?: number;
}
type CompleteUser = Required<OptionalUser>;
// Result: { name: string; email: string; age: number; }
Pick<T, K>
- Select specific properties:
interface BlogPost {
id: string;
title: string;
content: string;
author: string;
publishedAt: Date;
views: number;
}
type BlogSummary = Pick<BlogPost, "id" | "title" | "author">;
// Result: { id: string; title: string; author: string; }
Omit<T, K>
- Exclude specific properties:
type CreateUserRequest = Omit<User, "id" | "createdAt" | "updatedAt">;
// User minus the database-generated fields
Function-Related Utilities
Parameters<T>
- Extract function parameter types:
function authenticate(
username: string,
password: string,
rememberMe?: boolean
) {
// Authentication logic
}
type AuthParams = Parameters<typeof authenticate>;
// [username: string, password: string, rememberMe?: boolean]
// Useful for creating wrapper functions
function safeAuthenticate(...args: AuthParams) {
try {
return authenticate(...args);
} catch (error) {
console.error("Authentication failed:", error);
return null;
}
}
ReturnType<T>
- Extract function return type:
async function fetchUserProfile(id: string) {
return {
user: { id, name: "Alice", email: "alice@example.com" },
preferences: { theme: "dark", language: "en" },
};
}
type UserProfile = ReturnType<typeof fetchUserProfile>;
// Promise<{ user: {...}, preferences: {...} }>
type UserProfileData = Awaited<ReturnType<typeof fetchUserProfile>>;
// { user: {...}, preferences: {...} }
Real-World Type Engineering: Putting It All Together
Let’s build something practical that demonstrates these concepts working together:
// API Response modeling with discriminated unions
interface ApiSuccess<T> {
status: "success";
data: T;
timestamp: Date;
}
interface ApiError {
status: "error";
message: string;
code: number;
timestamp: Date;
}
interface ApiLoading {
status: "loading";
timestamp: Date;
}
type ApiResponse<T> = ApiSuccess<T> | ApiError | ApiLoading;
// Generic API client with type safety
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async request<T>(endpoint: string): Promise<ApiResponse<T>> {
try {
const response = await fetch(`${this.baseUrl}${endpoint}`);
if (!response.ok) {
return {
status: "error",
message: response.statusText,
code: response.status,
timestamp: new Date(),
};
}
const data = await response.json();
return {
status: "success",
data,
timestamp: new Date(),
};
} catch (error) {
return {
status: "error",
message: error instanceof Error ? error.message : "Unknown error",
code: 0,
timestamp: new Date(),
};
}
}
}
// Usage with full type safety
interface User {
id: string;
name: string;
email: string;
}
const client = new ApiClient("https://api.example.com");
async function loadUser(id: string) {
const response = await client.request<User>(`/users/${id}`);
switch (response.status) {
case "loading":
showSpinner();
break;
case "success":
// TypeScript knows response.data is User
console.log(`Loaded user: ${response.data.name}`);
break;
case "error":
// TypeScript knows response.message and response.code exist
console.error(
`Failed to load user: ${response.message} (${response.code})`
);
break;
default:
// Exhaustiveness checking
const exhaustive: never = response;
throw new Error(`Unhandled response type: ${exhaustive}`);
}
}
This example demonstrates:
- Discriminated unions for modeling different response states
- Generic types for reusable components
- Type guards through switch statements
- Exhaustiveness checking with
never
- Real-world error handling with type safety
What You’ve Mastered (And What’s Coming)
You’re no longer just someone who adds types to JavaScript. You understand how to model complex business logic with unions, combine behaviors with intersections, and create type-safe abstractions that scale. You can write code that not only works but communicates its intent clearly to both humans and the compiler.
Most importantly, you’ve learned to think in types. You understand that good TypeScript isn’t about adding type annotations everywhere; it’s about modeling your domain accurately so that invalid states become unrepresentable.
In our final part, we’ll take everything you’ve learned and apply it to real-world scenarios:
- React with TypeScript: Component props, hooks, and context that actually make sense
- Node.js APIs: Express middleware, database layers, and configuration management
- Testing strategies: Writing tests that leverage TypeScript’s type system
- Advanced patterns: Builder patterns, state machines, and architectural considerations
- Performance and tooling: Making your TypeScript build pipeline efficient
You’ve built the foundation. Now let’s construct something beautiful on top of it.
Ready to become the TypeScript developer who makes everyone else’s life easier? Part 4 is where theory meets practice, and where you stop being a student and start being the expert your team turns to when things get complicated.