NodeJs : Advanced TypeScript for Backend
Let's break down advanced TypeScript for backend development. Mastering these concepts is what distinguishes a senior developer, as it allows you to build systems that are not just functional but also robust, scalable, and less prone to bugs.
Part 1: The Three Pillars of Advanced Types
At the core of advanced TypeScript are three concepts that allow you to manipulate and create types programmatically. Understanding them deeply is non-negotiable for an 8-year-experience level developer.
1. Generics: Writing Reusable, Type-Safe Code
Core Concept: Generics are like function arguments but for types. They allow you to write a function or a class that can work with any type, while still maintaining full type safety for that specific type.
Think of a simple function that returns what you pass in. Without generics, you'd use any, losing all type information:
TypeScript
// Bad: Loses type information
function identity(arg: any): any {
return arg;
}
With generics, you create a type variable (commonly T for Type) that captures the type of the input and uses it for the output.
TypeScript
// Good: Preserves type information
function identity<T>(arg: T): T {
return arg;
}
const num = identity(10); // num is inferred as type 'number'
const str = identity("hello"); // str is inferred as type 'string'
Advanced Backend Application: A common use case is creating a standardized API response structure. You want the structure to be consistent, but the data payload will change for each endpoint.
TypeScript
// A generic wrapper for all our API responses
interface ApiResponse<T> {
success: boolean;
statusCode: number;
data: T;
error?: string;
}
function createSuccessResponse<T>(data: T): ApiResponse<T> {
return {
success: true,
statusCode: 200,
data: data,
};
}
// Usage for a user endpoint
interface User {
id: string;
name: string;
}
const userResponse = createSuccessResponse<User>({ id: '123', name: 'Alex' });
// userResponse.data.name is strongly typed!
// Usage for a product endpoint
interface Product {
sku: string;
price: number;
}
const productResponse = createSuccessResponse<Product>({ sku: 'abc', price: 99.99 });
// productResponse.data.price is strongly typed!
2. Mapped Types: Transforming Existing Types
Core Concept: Mapped types let you create new types by iterating over the properties of an existing type (in keyof T). This is incredibly powerful for creating variations of your data models without repeating code. The built-in Partial<T>, Readonly<T>, and Required<T> are all mapped types.
Let's look at how Partial<T> works under the hood:
TypeScript
// Makes all properties of T optional
type Partial<T> = {
[P in keyof T]?: T[P];
};
Advanced Backend Application: In a backend, you often need different "shapes" of the same data model. For example, when creating a user, the id and createdAt fields are not provided. When updating, all fields might be optional. Mapped types are perfect for this.
Let's create a custom mapped type that makes all properties of an object writable, which is useful when working with objects returned from a database that might be typed as readonly.
TypeScript
type Mutable<T> = {
-readonly [P in keyof T]: T[P]; // The '-' removes the 'readonly' modifier
};
// Imagine our ORM returns a readonly user
interface ReadonlyUser {
readonly id: string;
readonly name: string;
readonly email: string;
}
// We can create a mutable version for manipulation
type WritableUser = Mutable<ReadonlyUser>;
// Now you can modify properties on an object of type WritableUser
3. Conditional Types: Type-Level if/else
Core Concept: Conditional types allow you to choose a type based on a condition. They follow the structure T extends U ? X : Y, which means "if T is assignable to U, then the type is X, otherwise it's Y".
They are often combined with the infer keyword, which lets you "extract" a type from within another type during the condition check.
Advanced Backend Application: A common task is to know the type that a function returns. Or, more complexly, if a function returns a Promise, you want to get the type the promise resolves to. This is essential for creating robust service layers.
Let's build a utility type UnwrapPromise<T>:
TypeScript
// If T is a Promise of some type R, then the type is R. Otherwise, it's just T.
type UnwrapPromise<T> = T extends Promise<infer R> ? R : T;
// --- Example Usage ---
// A function that returns a user object directly
function getUserSync(id: string): User {
return { id, name: 'Alex' };
}
// A function that returns a user object within a promise
async function getUserAsync(id: string): Promise<User> {
return { id, name: 'Alex' };
}
// Let's get the return types
type SyncUser = UnwrapPromise<ReturnType<typeof getUserSync>>; // Type is User
type AsyncUser = UnwrapPromise<ReturnType<typeof getUserAsync>>; // Type is also User!
// Now you can use this type 'AsyncUser' in other parts of your system,
// knowing you have the actual data model type, regardless of how it was fetched.
Part 2: The Generic Repository Pattern
This task combines generics and other advanced types to create a highly reusable and type-safe database abstraction layer. This pattern is heavily used in frameworks like NestJS with TypeORM or Prisma.
Goal: Create a single Repository class that can handle CRUD for any data model (User, Product, Order) without rewriting the logic.
TypeScript
// 1. Define a base interface that all our models must have.
interface BaseEntity {
id: string | number;
}
// 2. Define our data models.
interface User extends BaseEntity {
id: string;
name: string;
email: string;
}
interface Product extends BaseEntity {
id: number;
sku: string;
price: number;
}
// 3. The Generic Repository Class
// T is a generic type parameter that MUST extend our BaseEntity
class GenericRepository<T extends BaseEntity> {
// In a real app, you would pass a database model/table reference here
// e.g., constructor(private model: SomeOrmModel<T>) {}
// The 'create' method should not accept an 'id'
async create(data: Omit<T, 'id'>): Promise<T> {
console.log('Creating a new record with data:', data);
// Real implementation: await this.model.create(data);
const newRecord = { id: 'new-id-from-db', ...data } as T;
return newRecord;
}
async findById(id: T['id']): Promise<T | null> {
console.log(`Finding record with id: ${id}`);
// Real implementation: await this.model.findById(id);
return null; // Placeholder
}
// The 'update' data should be partial
async update(id: T['id'], data: Partial<Omit<T, 'id'>>): Promise<T | null> {
console.log(`Updating record ${id} with:`, data);
// Real implementation: await this.model.update(id, data);
return null; // Placeholder
}
async delete(id: T['id']): Promise<boolean> {
console.log(`Deleting record with id: ${id}`);
// Real implementation: await this.model.delete(id);
return true;
}
}
// --- How to use it ---
const userRepository = new GenericRepository<User>();
userRepository.create({ name: 'Bob', email: 'bob@example.com' }); // Type-safe! 'id' is not allowed.
userRepository.update('some-user-id', { name: 'Robert' }); // Type-safe! Only user fields are allowed.
const productRepository = new GenericRepository<Product>();
productRepository.create({ sku: 'TSHIRT-RED', price: 25.00 });
// productRepository.update(123, { name: 'New Name' }); // ERROR! 'name' is not a property of Product.
Part 3: Compile-Time vs. Runtime Safety (Your Question)
"How can you use TypeScript to ensure both compile-time and runtime type safety for incoming API requests?"
This is a critical point that trips up many developers. TypeScript types are erased when compiled to JavaScript. This means interface User { ... } provides zero protection at runtime. An API client can send a POST request with a completely invalid body, and your code will break if you assume the data shape is correct.
The industry-standard solution is to use a runtime validation library that can infer TypeScript types. Zod is the leader here.
The workflow is:
Define a Schema: You define the shape and rules of your data using Zod. This is your "single source of truth".
Infer the Type: You use Zod's
inferfeature to automatically generate a static TypeScript type from the schema.Validate at Runtime: In your controller or API handler, you use the schema to
parse(validate) the incoming request body.
This gives you the best of both worlds:
Compile-time safety: The inferred type is used throughout your application, so TypeScript will catch errors if you try to access
user.emialinstead ofuser.email.Runtime safety: Zod's
parsemethod ensures that any data coming from the outside world (e.g., an API request) strictly conforms to your schema before your business logic runs.
Example (NestJS/Express Style):
TypeScript
import { z } from 'zod';
// 1. Define the SCHEMA (the single source of truth)
const createUserSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters long"),
email: z.string().email(),
age: z.number().positive().optional(),
});
// 2. INFER the TypeScript type from the schema
type CreateUserDto = z.infer<typeof createUserSchema>;
// This is equivalent to:
// type CreateUserDto = {
// name: string;
// email: string;
// age?: number | undefined;
// }
// --- In your Controller/Service ---
// `body` is typed as CreateUserDto for COMPILE-TIME safety.
// Your IDE will give you autocomplete for `body.name`, `body.email`, etc.
function createUser(body: CreateUserDto) {
// Business logic here...
console.log(`Creating user: ${body.name}`);
}
// --- In your API request handler (the entry point) ---
// req.body comes from the client and is unsafe (of type 'any')
function handleCreateUserRequest(req: any, res: any) {
try {
// 3. VALIDATE the raw, unsafe data at RUNTIME
const validatedBody = createUserSchema.parse(req.body);
// If validation passes, `validatedBody` is guaranteed to have the shape of CreateUserDto.
// We can now safely pass it to our business logic.
createUser(validatedBody);
res.status(201).send("User created");
} catch (error) {
// If validation fails, Zod throws a detailed error.
res.status(400).send(error);
}
}