← Back to DevBytes

NestJS TypeScript: Strongly Typed Applications

NestJS TypeScript: Strongly Typed Applications

NestJS is a progressive Node.js framework built with TypeScript from the ground up. It leverages TypeScript's robust type system to help developers build scalable, maintainable, and predictable server-side applications. The framework's architecture—heavily inspired by Angular—uses decorators, dependency injection, and modular design patterns that all benefit enormously from strong typing. This tutorial explores what strong typing means in the NestJS ecosystem, why it matters, and how to wield it effectively in your daily development workflow.

What Is Strong Typing in NestJS?

Strong typing in NestJS refers to the practice of explicitly declaring types for every variable, parameter, return value, and generic construct throughout your application. Rather than relying on any or implicit type inference, you define precise interfaces, classes, and type aliases that describe the shape of your data at compile time. NestJS itself is written in TypeScript and encourages this philosophy across its entire surface area—from controller methods and service functions to DTOs (Data Transfer Objects), guards, interceptors, pipes, and providers.

At its core, strong typing means the TypeScript compiler knows exactly what kind of data flows through your application. This knowledge enables IDE autocompletion, refactoring safety, and early detection of mismatched types before the code ever reaches production.

Why Strong Typing Matters

When building NestJS applications, strong typing delivers concrete benefits across multiple dimensions:

Core Typing Patterns in NestJS

Data Transfer Objects (DTOs) and Validation

DTOs are the primary mechanism for defining the shape of incoming request payloads. By creating strongly typed classes decorated with validation rules, you achieve a dual layer of safety: TypeScript enforces the structure at compile time, and the validation pipe enforces it at runtime.

// create-user.dto.ts
import { IsString, IsEmail, IsOptional, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class CreateUserDto {
  @ApiProperty({ description: 'User email address' })
  @IsEmail()
  email: string;

  @ApiProperty({ description: 'User full name' })
  @IsString()
  @MinLength(2)
  @MaxLength(100)
  name: string;

  @ApiProperty({ required: false, description: 'Optional phone number' })
  @IsOptional()
  @IsString()
  phone?: string;
}

In your controller, the typed DTO becomes part of the method signature. NestJS automatically instantiates and validates the DTO when you apply the appropriate decorator and pipe:

// users.controller.ts
import { Controller, Post, Body, UsePipes, ValidationPipe } from '@nestjs/common';
import { CreateUserDto } from './create-user.dto';
import { UsersService } from './users.service';
import { User } from './user.entity';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
  async create(@Body() createUserDto: CreateUserDto): Promise {
    return this.usersService.create(createUserDto);
  }
}

The @Body() decorator combined with the explicit CreateUserDto type tells TypeScript exactly what properties exist on the incoming object. The ValidationPipe with whitelist: true strips any extraneous properties not defined in the DTO, providing defense-in-depth against malicious or malformed requests.

Interfaces for Service Contracts

While NestJS uses classes for dependency injection tokens, interfaces shine when defining contracts between layers. They allow you to decouple implementations from consumers and enable easy mocking during testing.

// interfaces/user-repository.interface.ts
import { User } from '../entities/user.entity';

export interface IUserRepository {
  findById(id: number): Promise;
  findByEmail(email: string): Promise;
  create(data: Partial): Promise;
  update(id: number, data: Partial): Promise;
  delete(id: number): Promise;
}
// users.service.ts
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { IUserRepository } from './interfaces/user-repository.interface';
import { User } from './user.entity';
import { CreateUserDto } from './create-user.dto';

@Injectable()
export class UsersService {
  constructor(
    @Inject('USER_REPOSITORY') private readonly userRepository: IUserRepository,
  ) {}

  async create(dto: CreateUserDto): Promise {
    const existing = await this.userRepository.findByEmail(dto.email);
    if (existing) {
      throw new Error('Email already in use');
    }
    return this.userRepository.create(dto);
  }

  async findById(id: number): Promise {
    const user = await this.userRepository.findById(id);
    if (!user) {
      throw new NotFoundException(`User with id ${id} not found`);
    }
    return user;
  }
}

By typing the injected dependency as IUserRepository rather than a concrete class, the service becomes testable with any compliant implementation—including in-memory fakes or mock objects created by testing libraries.

Leveraging Generic Types

Generics allow you to create reusable, type-safe abstractions. NestJS itself uses generics extensively—for example, in the Repository pattern provided by TypeORM or MikroORM integrations.

// generic-crud.service.ts
import { Repository } from 'typeorm';
import { TypeOrmCrudService } from '@nestjsx/crud-typeorm';

export abstract class GenericCrudService {
  constructor(protected readonly repository: Repository) {}

  async findAll(): Promise {
    return this.repository.find();
  }

  async findOne(id: number): Promise {
    return this.repository.findOne({ where: { id } as any });
  }

  async create(data: Partial): Promise {
    const entity = this.repository.create(data as any);
    return this.repository.save(entity);
  }

  async remove(id: number): Promise {
    await this.repository.delete(id);
  }
}

When extending this generic service for a specific entity, TypeScript propagates the type parameter throughout all inherited methods:

// products.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Product } from './product.entity';
import { GenericCrudService } from '../common/generic-crud.service';

@Injectable()
export class ProductsService extends GenericCrudService {
  constructor(
    @InjectRepository(Product)
    private readonly productRepository: Repository,
  ) {
    super(productRepository);
  }

  // findAll() automatically returns Promise
  // findOne() automatically returns Promise
  // No need to redefine types—TypeScript infers them from the generic parameter
}

Typed Custom Decorators

NestJS's decorator system benefits from strong typing when you create custom parameter decorators. By specifying the return type, consumers of your decorator know exactly what value they receive.

// current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';

export interface AuthenticatedUser {
  id: number;
  email: string;
  roles: string[];
}

export const CurrentUser = createParamDecorator(
  (data: unknown, ctx: ExecutionContext): AuthenticatedUser => {
    const request = ctx.switchToHttp().getRequest();
    return request.user as AuthenticatedUser;
  },
);
// profile.controller.ts
import { Controller, Get } from '@nestjs/common';
import { CurrentUser, AuthenticatedUser } from './current-user.decorator';

@Controller('profile')
export class ProfileController {
  @Get()
  getProfile(@CurrentUser() user: AuthenticatedUser) {
    // TypeScript knows user has id, email, and roles
    return {
      id: user.id,
      email: user.email,
      roles: user.roles,
    };
  }
}

Properly Typed Asynchronous Operations

Every async method in NestJS should explicitly declare its Promise return type. This prevents accidental omissions of await and ensures callers handle the asynchronous nature correctly.

// Bad: return type is inferred as Promise
@Injectable()
export class UnclearService {
  async fetchData(url: string) {
    const response = await fetch(url);
    return response.json();
  }
}

// Good: explicit return type
@Injectable()
export class ClearService {
  async fetchData(url: string): Promise<{ id: number; value: string }> {
    const response = await fetch(url);
    return response.json() as Promise<{ id: number; value: string }>;
  }
}

Advanced Typing Techniques

Mapped Types for DTO Transformation

NestJS provides built-in mapped type helpers (PartialType, PickType, OmitType, IntersectionType) that generate new DTO classes from existing ones while preserving decorator metadata. These are invaluable for update operations where only a subset of fields is required.

// update-user.dto.ts
import { PartialType, OmitType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';

// All fields become optional
export class UpdateUserDto extends PartialType(CreateUserDto) {}

// Pick only specific fields and make them optional
export class ChangeEmailDto extends PartialType(
  OmitType(CreateUserDto, ['name', 'phone'] as const),
) {}

The resulting UpdateUserDto class has every property from CreateUserDto marked as optional, while retaining all class-validator decorators. TypeScript understands the structural change, so code using UpdateUserDto benefits from accurate type narrowing.

Conditional Types for Flexible Responses

Sometimes your API responses vary based on query parameters. Conditional types allow you to model these variations precisely.

// types/api-response.types.ts
export interface UserSummary {
  id: number;
  name: string;
}

export interface UserDetail extends UserSummary {
  email: string;
  phone: string;
  createdAt: Date;
  updatedAt: Date;
}

export type UserResponse = T extends 'detail'
  ? UserDetail
  : UserSummary;
// users.controller.ts
@Controller('users')
export class UsersController {
  @Get(':id')
  async getUser(
    @Param('id') id: number,
    @Query('view') view: 'summary' | 'detail',
  ): Promise> {
    const user = await this.usersService.findById(id);
    if (view === 'detail') {
      return {
        id: user.id,
        name: user.name,
        email: user.email,
        phone: user.phone,
        createdAt: user.createdAt,
        updatedAt: user.updatedAt,
      };
    }
    return { id: user.id, name: user.name };
  }
}

Template Literal Types for String Patterns

TypeScript's template literal types can enforce string formats at compile time—useful for status enums, role names, or event patterns in NestJS microservices.

// types/status.types.ts
export type OrderStatus = 
  | 'pending'
  | 'confirmed'
  | 'shipped'
  | 'delivered'
  | 'cancelled';

export type StatusTransition = `${OrderStatus}_to_${OrderStatus}`;

// Valid: 'pending_to_confirmed', 'shipped_to_delivered'
// Invalid: 'pending_to_invalid', 'unknown_status'
// order-events.service.ts
import { Injectable } from '@nestjs/common';
import { OrderStatus, StatusTransition } from '../types/status.types';

@Injectable()
export class OrderEventsService {
  private readonly validTransitions: Record = {
    'pending_to_confirmed': true,
    'confirmed_to_shipped': true,
    'shipped_to_delivered': true,
    'pending_to_cancelled': true,
    'confirmed_to_cancelled': true,
    // TypeScript would error here if we added an invalid transition
  };

  isValidTransition(from: OrderStatus, to: OrderStatus): boolean {
    const transition: StatusTransition = `${from}_to_${to}`;
    return this.validTransitions[transition] ?? false;
  }
}

Best Practices for Strongly Typed NestJS Applications

Discriminated Union Example

// types/order.types.ts
interface OrderPending {
  status: 'pending';
  createdAt: Date;
  items: string[];
}

interface OrderConfirmed {
  status: 'confirmed';
  createdAt: Date;
  confirmedAt: Date;
  items: string[];
}

interface OrderCancelled {
  status: 'cancelled';
  createdAt: Date;
  cancelledAt: Date;
  reason: string;
}

export type Order = OrderPending | OrderConfirmed | OrderCancelled;

// order-processor.service.ts
function processOrder(order: Order): string {
  switch (order.status) {
    case 'pending':
      // TypeScript knows this is OrderPending — no confirmedAt available
      return `Order created at ${order.createdAt.toISOString()}`;
    case 'confirmed':
      // TypeScript knows this is OrderConfirmed — confirmedAt is available
      return `Order confirmed at ${order.confirmedAt.toISOString()}`;
    case 'cancelled':
      // TypeScript knows this is OrderCancelled — reason is available
      return `Order cancelled: ${order.reason}`;
  }
}

Typed Configuration and Environment Variables

Strong typing extends to your application configuration. Instead of accessing process.env ad-hoc throughout the codebase, define a typed configuration interface and register it with NestJS's config module.

// config/configuration.ts
export interface AppConfig {
  port: number;
  database: {
    host: string;
    port: number;
    username: string;
    password: string;
    database: string;
  };
  jwt: {
    secret: string;
    expiresIn: string;
  };
}

export default (): AppConfig => ({
  port: parseInt(process.env.PORT ?? '3000', 10),
  database: {
    host: process.env.DB_HOST ?? 'localhost',
    port: parseInt(process.env.DB_PORT ?? '5432', 10),
    username: process.env.DB_USERNAME ?? 'postgres',
    password: process.env.DB_PASSWORD ?? '',
    database: process.env.DB_NAME ?? 'myapp',
  },
  jwt: {
    secret: process.env.JWT_SECRET ?? 'default-secret',
    expiresIn: process.env.JWT_EXPIRES_IN ?? '1d',
  },
});
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import configuration from './config/configuration';

@Module({
  imports: [
    ConfigModule.forRoot({
      load: [configuration],
      isGlobal: true,
    }),
  ],
})
export class AppModule {}
// some.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppConfig } from '../config/configuration';

@Injectable()
export class SomeService {
  constructor(private readonly configService: ConfigService) {}

  getDatabaseHost(): string {
    // TypeScript knows the shape of the config — safe dot-access
    return this.configService.get('database.host');
  }
}

By passing AppConfig as the generic parameter to ConfigService, you gain full type safety when accessing configuration values throughout your application.

Testing with Strong Types

Strong typing also improves your testing experience. Mock implementations must conform to the same interfaces as production code, catching mismatches early.

// __tests__/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from '../users.service';
import { IUserRepository } from '../interfaces/user-repository.interface';
import { User } from '../user.entity';

describe('UsersService', () => {
  let service: UsersService;
  let mockRepo: jest.Mocked;

  beforeEach(async () => {
    mockRepo = {
      findById: jest.fn(),
      findByEmail: jest.fn(),
      create: jest.fn(),
      update: jest.fn(),
      delete: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        { provide: 'USER_REPOSITORY', useValue: mockRepo },
      ],
    }).compile();

    service = module.get(UsersService);
  });

  it('should throw NotFoundException when user does not exist', async () => {
    mockRepo.findById.mockResolvedValue(null);
    await expect(service.findById(999)).rejects.toThrow('User with id 999 not found');
  });

  it('should return user when found', async () => {
    const mockUser: User = {
      id: 1,
      email: 'test@example.com',
      name: 'Test User',
      createdAt: new Date(),
      updatedAt: new Date(),
    };
    mockRepo.findById.mockResolvedValue(mockUser);
    const result = await service.findById(1);
    expect(result).toEqual(mockUser);
  });
});

The jest.Mocked<IUserRepository> type ensures that your mock object implements every method of the interface. If the interface changes, the test file produces a compile error rather than a subtle runtime bug.

Conclusion

Strong typing in NestJS is not merely a stylistic preference—it is a foundational engineering practice that transforms how you build, maintain, and scale server-side applications. By embracing explicit types across DTOs, services, generics, decorators, and configuration, you create a codebase that is resilient to change, self-documenting, and dramatically easier to debug. The combination of compile-time type checking with runtime validation pipes gives you defense-in-depth that catches errors at every stage of the development lifecycle. As your NestJS application grows in complexity, the investment in strong typing pays compounding dividends: faster onboarding for new team members, safer refactoring, and a level of confidence that lets you ship features without anxiety. Make strong typing the default in every NestJS project you build—your future self and your teammates will thank you.

— Ad —

Google AdSense will appear here after approval

← Back to all articles