Modern Software Engineering Beyond the Basics
Advanced TypeScript Design
Advanced TypeScript: From Types to Tools
You already know how TypeScript helps you catch errors before they happen. Now, let’s move beyond basic type annotations and use TypeScript’s advanced features as powerful design tools. We'll explore how to create precise, expressive types that enforce your application's logic, not just describe its shape. This approach is key to building robust, maintainable systems where the type system itself prevents entire classes of bugs.
Crafting Precise Types
Let's start by shaping our types more precisely. Standard types like string or number are often too broad. We need ways to create types that only allow specific, valid values.
Template Literal Types let you construct new string literal types from other types. They work like template strings in JavaScript, but at the type level. This is incredibly useful for creating types that represent combinations of strings, like CSS class names or event keys.
type Margin = 'm' | 'mt' | 'mr' | 'mb' | 'ml';
type Size = '0' | '1' | '2' | '3' | '4';
// Creates types like "m-0", "mt-1", etc.
type MarginClass = `${Margin}-${Size}`;
let boxMargin: MarginClass = 'mb-2'; // OK
// let invalidMargin: MarginClass = 'mx-2'; // Error! 'mx-2' is not in the type.
// You can also use primitives:
type PaddingValue = `p-${number}`;
let p1: PaddingValue = 'p-10'; // OK
let p2: PaddingValue = 'p-12.5'; // OK
// let p3: PaddingValue = 'p-ten'; // Error!
Mapped Types allow you to create new types by transforming the properties of an existing type. This follows the Don't Repeat Yourself (DRY) principle at the type level. You can take an interface and, for example, make all its properties optional or read-only without rewriting the entire structure.
interface UserProfile {
name: string;
email: string;
isVerified: boolean;
}
// A mapped type that makes all properties of T optional
type Partial<T> = {
[Property in keyof T]?: T[Property];
};
type PartialUserProfile = Partial<UserProfile>;
/*
Equivalent to:
{
name?: string;
email?: string;
isVerified?: boolean;
}
*/
Ensuring Domain Integrity with Branded Types
TypeScript uses a structural type system. This means if two types have the same shape, they're considered compatible. Sometimes, this is too loose. For example, a UserID and a ProductID might both be strings, but they aren't interchangeable in your application's logic. Passing a product ID where a user ID is expected is a bug waiting to happen.
Branded Types (also known as Opaque Types) solve this by adding a unique, non-existent property to a type. This makes the type structurally incompatible with other types of the same underlying shape, effectively giving us nominal typing.
// A generic 'Brand' type
type Brand<K, T> = K & { __brand: T };
// Create distinct types for UserID and ProductID
type UserID = Brand<string, 'UserID'>;
type ProductID = Brand<string, 'ProductID'>;
function getUser(id: UserID) {
// Fetches a user by their ID
}
// We use type assertion to "brand" a string
const myUserId = 'user-123' as UserID;
const myProductId = 'prod-456' as ProductID;
getUser(myUserId); // This is valid
// Error: Argument of type 'ProductID' is not assignable
// to parameter of type 'UserID'.
// getUser(myProductId);
This technique prevents you from accidentally mixing up different kinds of identifiers, making your code safer and your domain logic clearer.
Satisfies vs. As
You're likely familiar with the as keyword for type assertions. It tells the compiler, "Trust me, I know what I'm doing." While useful, as can be dangerous because it can override the compiler's type checking, potentially hiding errors.
Enter the satisfies operator. It allows you to check if a value conforms to a type without changing its inferred type. This gives you the best of both worlds: you get autocompletion and type-checking based on the broader type, but you retain the specific literal type of the value itself.
// Define a generic configuration type
type Config = {
[key: string]: { route: string };
};
// Using 'as'
const config1 = {
home: { route: '/' },
about: { route: '/about' },
} as Config;
// Problem: config1.home.route is now just 'string', not '/'
// Using 'satisfies'
const config2 = {
home: { route: '/' },
about: { route: '/about' },
} satisfies Config;
// No problem here! Type safety is preserved.
const homeRoute = config2.home.route; // Type is '/' (the literal string)
const aboutRoute = config2.about.route; // Type is '/about'
// TypeScript will also catch errors if the structure is wrong
const badConfig = {
contact: { url: '/contact' } // 'url' is not 'route'
} satisfies Config; // Error!
astells the compiler what a type is.satisfiesasks the compiler to check if a value fits a type, while preserving the value's original, more specific type.
Runtime Validation with Zod
TypeScript's type system is fantastic, but it has a fundamental limitation: it only exists at compile time. Once your code is compiled to JavaScript, all type information is erased. This means you can't rely on TypeScript to validate data coming from external sources, like an API response or user input.
This is where runtime validation libraries like Zod come in. Zod allows you to define a schema that is used to both validate data at runtime and infer a static TypeScript type.
import { z } from 'zod';
// 1. Define a schema for your data
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2),
email: z.string().email(),
isAdmin: z.boolean().optional(),
});
// 2. Infer the TypeScript type directly from the schema
type User = z.infer<typeof UserSchema>;
/*
type User = {
id: string;
name: string;
email: string;
isAdmin?: boolean | undefined;
}
*/
// 3. Use the schema to parse and validate runtime data
function processUser(data: unknown) {
const result = UserSchema.safeParse(data);
if (result.success) {
// result.data is now safely typed as 'User'
console.log('Valid user:', result.data.name);
} else {
// result.error contains detailed validation errors
console.error('Invalid data:', result.error.flatten());
}
}
// Example usage
processUser({ name: 'Alice', email: 'alice@example.com', id: 'some-uuid' }); // Invalid ID
processUser({ name: 'Bob', email: 'bob@example.com', id: '123e4567-e89b-12d3-a456-426614174000' }); // Valid
By defining your data structures once with Zod, you get a single source of truth for both runtime validation and static type checking. This is especially powerful for full-stack applications, where you can share these schemas between your frontend and backend to ensure data consistency.
Strict Compiler Settings
To get the most out of TypeScript, you should enable its strictest settings. This helps you catch more potential errors at compile time. Two particularly useful flags in your tsconfig.json are noUncheckedIndexedAccess and exactOptionalPropertyTypes.
"noUncheckedIndexedAccess": trueThis flag adds
| undefinedto any property accessed via an index signature. It forces you to handle cases where an object key or array index might not exist, preventing common runtime errors.
// With noUncheckedIndexedAccess: true
const userAges: { [name: string]: number } = {
'Alice': 30,
'Bob': 25
};
const charlieAge = userAges['Charlie']; // Type is number | undefined
// console.log(charlieAge.toFixed(2)); // Error! 'charlieAge' could be undefined.
if (charlieAge !== undefined) {
console.log(charlieAge.toFixed(2)); // OK
}
"exactOptionalPropertyTypes": trueBy default, TypeScript allows you to assign
undefinedto an optional property. This flag changes that behavior, forcing you to explicitly include the property if it's defined, even with anundefinedvalue. It helps differentiate between a property that is absent and a property that is present but has the valueundefined.
interface UserProfile {
name: string;
bio?: string; // Optional property
}
// With exactOptionalPropertyTypes: true
const user1: UserProfile = { name: 'Alice' }; // OK, bio is absent
// Error: 'undefined' is not assignable to 'string'
const user2: UserProfile = { name: 'Bob', bio: undefined };
// If you need to allow `undefined` explicitly:
interface UserProfileWithUndefined {
name: string;
bio?: string | undefined;
}
const user3: UserProfileWithUndefined = { name: 'Charlie', bio: undefined }; // OK
Adopting these advanced features and strict settings transforms TypeScript from a simple type-checker into a powerful tool for designing robust and predictable applications. They help ensure that your types accurately reflect your domain logic, from the front-end to the back-end.