Categories Javascript

JavaScript to TypeScript Migration Guide

TypeScript is a powerful superset of JavaScript that adds static typing to the language you already know and love. Think of it as JavaScript with superpowers – it provides all the features you’re familiar with while adding robust type-checking and enhanced development tools.

In this guide, you’ll learn:

  • How to transition from JavaScript to TypeScript smoothly
  • Essential TypeScript concepts and features
  • Practical migration strategies and best practices
  • Real-world problem-solving with TypeScript

The Case for TypeScript

Advantages over JavaScript

Static Typing Benefits

TypeScript’s most significant advantage is its static typing system. Unlike JavaScript’s dynamic typing, TypeScript allows you to catch errors before runtime:

// JavaScript - This error only appears at runtime
const user = { name: 'John' };
// Runtime error: Cannot read properties of undefined (reading 'toString')
console.log(user.age.toString()); 

// TypeScript - Error caught during development
interface User {
  name: string;
}
const user: User = { name: 'John' };
// Compilation error: Property 'age' does not exist on type 'User'
console.log(user.age.toString()); 

// TypeScript with optional properties
interface UserWithOptional {
  name: string;
  age?: number; // The ? makes this property optional
}

const user2: UserWithOptional = { name: 'Jane' };
// Safe access with optional chaining
// No error, outputs: "Age not provided"
console.log(user2.age?.toString() || 'Age not provided'); 

Better Error Catching

TypeScript helps prevent common JavaScript errors:

  • Undefined property access
  • Type mismatches
  • Missing function arguments
  • Incorrect function calls

Improved Maintainability

TypeScript code is self-documenting and easier to maintain, especially in large codebases. The type system serves as living documentation that stays up-to-date with your code.

Common Misconceptions Addressed

  • “TypeScript is a completely different language” – False! It’s a superset of JavaScript, meaning all valid JavaScript is valid TypeScript.
  • “It requires a complete rewrite” – Not true! You can migrate gradually, file by file.
  • “It adds runtime overhead” – Actually, TypeScript is removed during compilation, resulting in clean JavaScript with no runtime cost.

Getting Started with TypeScript

Development Environment Setup

Setting up a TypeScript development environment is straightforward. You’ll need to install TypeScript and configure your tools to work with it. This section guides you through the initial setup process to get you productive quickly.

# Install TypeScript globally
npm install -g typescript

# Or locally in your project (recommended for version consistency)
npm install typescript --save-dev

Installing TypeScript

After installation, verify your setup:

# Check TypeScript version
tsc --version

# Create a TypeScript configuration file
npx tsc --init

Configuring your IDE

For Visual Studio Code:

  1. Install the TypeScript extension
  2. Enable automatic type acquisition
  3. Configure TypeScript IntelliSense
// VSCode settings.json example
{
  "typescript.suggest.autoImports": true,
  "typescript.updateImportsOnFileMove.enabled": "always",
  "typescript.tsdk": "node_modules/typescript/lib", // Use local version
  "editor.codeActionsOnSave": {
    "source.organizeImports": true
  }
}

Essential Tools and Extensions

  • ESLint with TypeScript support
  • Prettier for code formatting
  • TypeScript Debug Extension

Basic TypeScript Configuration

Understanding how to configure TypeScript is essential for a smooth development experience. The tsconfig.json file controls how TypeScript compiles your code and provides many options to customize the behavior to your needs.

Creating tsconfig.json

Initialize TypeScript configuration:

tsc --init

Key Configuration Options

{
  "compilerOptions": {
    // Specify ECMAScript target version
    "target": "es2016",  
    
    // Module code generation method
    "module": "commonjs",
    
    // Enable all strict type checking options
    "strict": true,
    
    // Allow default imports from modules with no default export
    "esModuleInterop": true,
    
    // Skip type checking of declaration files
    "skipLibCheck": true,
    
    // Ensure correct casing in imports
    "forceConsistentCasingInFileNames": true,
    
    // Output directory for compiled files
    "outDir": "./dist",
    
    // Root directory of input files
    "rootDir": "./src"
  },
  
  // Files to include in compilation
  "include": ["src/**/*"],
  
  // Files to exclude from compilation
  "exclude": ["node_modules", "**/*.spec.ts"]
}

TypeScript vs JavaScript: Key Differences

Type System Introduction

TypeScript’s type system is what sets it apart from JavaScript. Understanding the basic types and how to use them effectively is the foundation of TypeScript development. This section introduces you to TypeScript’s core type system concepts.

Basic Types

// JavaScript variables - types are implicit
let name = 'John';
let age = 30;
let isActive = true;

// TypeScript variables with explicit type annotations
let name: string = 'John';
let age: number = 30;
let isActive: boolean = true;

// Additional primitive types
let notFound: null = null;
let notDefined: undefined = undefined;
let symbolValue: symbol = Symbol('unique');
let bigInt: bigint = 100n;

// Arrays
let numbers: number[] = [1, 2, 3];
let names: Array<string> = ['Alice', 'Bob']; // Generic array type

// Tuples - fixed-length arrays with specific types
let user: [string, number] = ['John', 30]; // Name and age

// Any - opt out of type checking (use sparingly)
let dynamicValue: any = 4;
dynamicValue = 'a string';
dynamicValue = true;

// Void - absence of a type (commonly used for functions)
function logMessage(message: string): void {
  console.log(message);
}

// Never - represents values that never occur
function throwError(message: string): never {
  throw new Error(message);
}

// Unknown - safer alternative to any
let userInput: unknown = getUserInput();
if (typeof userInput === 'string') {
  // Type narrowing allows safe operations
  console.log(userInput.toUpperCase());
}

Type Inference

TypeScript can often infer types automatically, reducing the need for explicit annotations while still providing type safety:

// Type inference in action
let message = 'Hello'; // TypeScript infers string type
let numbers = [1, 2, 3]; // TypeScript infers number[] type

// Return type inference
function add(a: number, b: number) {
  return a + b; // Return type inferred as number
}

// Contextual typing
document.addEventListener('click', function(event) {
  // TypeScript knows 'event' is a MouseEvent
  console.log(event.button); // Type-safe access to MouseEvent properties
});

// Inference with destructuring
const [first, second] = [8, 'hello']; // first: number, second: string

// Type inference limitations
const mixedArray = [1, 'string', true]; // Inferred as (string | number | boolean)[]

Syntax

Interfaces

Interfaces are a powerful way to define contracts within your code. They describe the shape that objects must conform to, enabling type checking and better code documentation.

// Basic interface definition
interface User {
  id: number;
  name: string;
  email: string;
  isActive?: boolean; // Optional property (may be undefined)
  readonly createdAt: Date; // Can't be modified after initialization
}

// Implementing the interface
const newUser: User = {
  id: 1,
  name: 'John',
  email: 'john@example.com',
  createdAt: new Date()
};

// Extending interfaces
interface Employee extends User {
  department: string;
  salary: number;
}

// Implementing an interface with a class
class AdminUser implements User {
  id: number;
  name: string;
  email: string;
  readonly createdAt: Date;
  permissions: string[];
  
  constructor(id: number, name: string, email: string) {
    this.id = id;
    this.name = name;
    this.email = email;
    this.createdAt = new Date();
    this.permissions = ['read', 'write', 'delete'];
  }
}

// Index signatures for dynamic properties
interface Dictionary {
  [key: string]: string | number;
}

const config: Dictionary = {
  apiUrl: 'https://api.example.com',
  timeout: 3000,
  version: '1.0.0'
};

// Function interfaces
interface SearchFunc {
  (source: string, subString: string): boolean;
}

const mySearch: SearchFunc = function(src, sub) {
  return src.indexOf(sub) > -1;
};

Enums

Enumerations (or enums) provide a way to define a set of named constants. They’re particularly useful when you have a fixed set of values that a variable can take. TypeScript enums make your code more readable and self-documenting by giving meaningful names to numeric or string values.

// String enum example - great for API integration where values matter
enum UserRole {
  Admin = 'ADMIN',    // Using string values makes debugging easier
  Editor = 'EDITOR',  // These strings will appear directly in runtime code
  Viewer = 'VIEWER'   // and network requests
}

// Using the enum to ensure type safety
const userRole: UserRole = UserRole.Admin;

// TypeScript prevents invalid assignments
// Error: Type '"MANAGER"' is not assignable to type 'UserRole'
// const invalidRole: UserRole = 'MANAGER'; 

// You can also create numeric enums
enum HttpStatus {
  OK = 200,
  BadRequest = 400,
  Unauthorized = 401,
  NotFound = 404
}

function handleResponse(status: HttpStatus) {
  if (status === HttpStatus.OK) {
    console.log('Request successful');
  }
}

// Const enums are completely removed during compilation for performance
const enum Direction {
  Up,
  Down,
  Left,
  Right
}

// Usage of const enum
function move(direction: Direction) {
  switch (direction) {
    case Direction.Up:
      return { x: 0, y: -1 };
    case Direction.Down:
      return { x: 0, y: 1 };
    case Direction.Left:
      return { x: -1, y: 0 };
    case Direction.Right:
      return { x: 1, y: 0 };
  }
}

Generics

Generics are one of TypeScript’s most powerful features, allowing you to create reusable components that can work with a variety of types rather than a single one. They help you build components that maintain type safety while providing flexibility.

// A generic function that works with any type
function getFirstElement<T>(array: T[]): T | undefined {
  return array[0];
}

// TypeScript infers the return type based on the input
const numbers = getFirstElement([1, 2, 3]); // Type: number
const strings = getFirstElement(['a', 'b', 'c']); // Type: string

// Generic functions can enforce constraints
function mergeObjects<T extends object, U extends object>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

const merged = mergeObjects({ name: 'John' }, { age: 30 });
// Result type: { name: string, age: number }

// Generic interfaces for flexible data structures
interface Repository<T> {
  findById(id: number): T | undefined;
  save(item: T): void;
  getAll(): T[];
}

// Implementation for a specific type
class UserRepository implements Repository<User> {
  private users: User[] = [];
  
  findById(id: number): User | undefined {
    return this.users.find(user => user.id === id);
  }
  
  save(user: User): void {
    this.users.push(user);
  }
  
  getAll(): User[] {
    return [...this.users];
  }
}

// Generic type aliases
type Nullable<T> = T | null;
type Result<T> = { success: true, data: T } | { success: false, error: Error };

// Using the Result type for error handling
function fetchData<T>(url: string): Promise<Result<T>> {
  return fetch(url)
    .then(response => {
      if (!response.ok) {
        throw new Error(`HTTP error: ${response.status}`);
      }
      return response.json();
    })
    .then(data => ({ success: true, data }))
    .catch(error => ({ success: false, error }));
}

Object-Oriented Features

TypeScript enhances JavaScript’s object-oriented programming capabilities with features like access modifiers, abstract classes, and interfaces. These additions make it easier to implement robust OOP patterns and enforce encapsulation.

Classes

Classes in TypeScript extend JavaScript’s class syntax with type annotations and access modifiers. This allows you to clearly define the visibility and type safety of your class members.

// A fully typed TypeScript class with access modifiers
class Employee {
  // Private members are only accessible within the class
  private id: number;
  
  // Protected members are accessible within the class and subclasses
  protected name: string;
  
  // Public members are accessible from anywhere (default if not specified)
  public department: string;

  constructor(id: number, name: string, department: string) {
    this.id = id;
    this.name = name;
    this.department = department;
  }

  // Methods with return type annotations
  getDetails(): string {
    return `${this.name} works in ${this.department}`;
  }
  
  // Private methods can only be called from within the class
  private generateEmployeeCode(): string {
    return `EMP-${this.id}`;
  }
}

// Class inheritance with proper type checking
class Manager extends Employee {
  private subordinates: Employee[] = [];
  
  constructor(id: number, name: string) {
    super(id, name, 'Management');
  }
  
  addSubordinate(employee: Employee): void {
    this.subordinates.push(employee);
  }
  
  // We can access the protected 'name' property from the parent class
  getManagerDetails(): string {
    return `Manager: ${this.name} oversees ${this.subordinates.length} employees`;
  }
}

// Abstract classes provide a base for other classes
abstract class Shape {
  protected color: string;
  
  constructor(color: string) {
    this.color = color;
  }
  
  // Abstract methods must be implemented by derived classes
  abstract getArea(): number;
  
  // Concrete methods can be inherited as-is
  describe(): string {
    return `A ${this.color} shape with area ${this.getArea()}`;
  }
}

// Implementing an abstract class
class Circle extends Shape {
  private radius: number;
  
  constructor(color: string, radius: number) {
    super(color);
    this.radius = radius;
  }
  
  getArea(): number {
    return Math.PI * this.radius * this.radius;
  }
}

// Property shorthand with access modifiers
class Person {
  // This shorthand automatically creates and initializes class properties
  constructor(
    public name: string,
    private age: number,
    protected email: string
  ) {}
  
  greet(): string {
    return `Hello, my name is ${this.name}`;
  }
}

Your First TypeScript Code

Now that we’ve covered TypeScript’s core features, let’s put this knowledge into practice by converting JavaScript code to TypeScript. This hands-on approach will help you understand the practical aspects of the migration process.

Converting a Simple JavaScript File

Let’s take a JavaScript file and convert it step-by-step, adding type information and leveraging TypeScript’s features. This example demonstrates a common pattern you’ll encounter when migrating real-world code.

// Original JavaScript (user.js)
function createUser(name, age) {
  return {
    name,
    age,
    greet() {
      return `Hello, ${this.name}!`;
    }
  };
}

const user = createUser('John', 30);
console.log(user.greet());

// Add a new property, JavaScript doesn't complain
user.role = 'admin';
// Converted TypeScript (user.ts)
// Step 1: Define the shape of our user object
interface User {
  name: string;
  age: number;
  greet(): string;
  role?: string; // Make role optional since it might be added later
}

// Step 2: Add type annotations to the function
function createUser(name: string, age: number): User {
  return {
    name,
    age,
    greet() {
      return `Hello, ${this.name}!`;
    }
  };
}

// Step 3: Add type annotation to the variable
const user: User = createUser('John', 30);
console.log(user.greet());

// TypeScript now provides type safety
user.role = 'admin'; // OK because role is in the interface
// user.position = 'developer'; // Error: Property 'position' does not exist on type 'User'

// Step 4: Enhance with more TypeScript features
// For example, using union types for role
type UserRole = 'admin' | 'editor' | 'viewer';

interface EnhancedUser extends User {
  role?: UserRole;
}

function createEnhancedUser(name: string, age: number, role?: UserRole): EnhancedUser {
  return {
    ...createUser(name, age),
    ...(role && { role })
  };
}

const admin = createEnhancedUser('Jane', 28, 'admin');
// Error: Argument of type '"manager"' is not assignable to parameter
// of type 'UserRole | undefined'
// const invalid = createEnhancedUser('Bob', 35, 'manager'); 

Running TypeScript Code

To compile and run TypeScript, you’ll use the TypeScript compiler (tsc). Here’s how to set up a workflow for development and production:

# Compile a single file
tsc user.ts

# Watch mode for development (recompiles on changes)
tsc --watch

# Compile entire project according to tsconfig.json
tsc --project tsconfig.json

# Run with ts-node for development (no separate compile step)
npx ts-node user.ts

# Integrated npm script example (add to package.json)
# "scripts": {
#   "build": "tsc",
#   "dev": "ts-node src/index.ts",
#   "start": "node dist/index.js"
# }

Debugging TypeScript

TypeScript makes debugging easier with source maps, which help you debug the original TypeScript code even though the runtime is executing JavaScript:

{
  "compilerOptions": {
    "sourceMap": true,
    "inlineSourceMap": false,
    "inlineSources": true
  }
}
// Example of setting up a debugging utility
function debug<T>(value: T, label?: string): T {
  if (label) {
    console.log(`[DEBUG] ${label}:`);
  }
  console.log(JSON.stringify(value, null, 2));
  return value;
}

// Usage in code
const result = calculateComplexValue();
debug(result, 'Complex calculation result');

Common Migration Challenges

Handling ‘any’ Type

The ‘any’ type effectively opts out of type checking, which can be useful during migration but should be minimized for best results. Here are strategies for working with it:

// Temporary solution during migration
let legacyData: any = getLegacyData();

// Better approach with type assertion
interface LegacyData {
  id: number;
  content: string;
  metadata?: Record<string, unknown>;
}

// Type assertion when you know the shape but TypeScript doesn't
let legacyData = getLegacyData() as LegacyData;

// Using unknown instead of any (safer approach)
function processResponse(response: unknown): string {
  // Type checking before operations
  if (typeof response === 'string') {
    return response.toUpperCase();
  }
  
  // Type guard for object with specific property
  if (response && typeof response === 'object' && 'message' in response) {
    return String((response as { message: unknown }).message);
  }
  
  return 'Unknown response';
}

// Progressive refinement: start with any, refine over time
// Stage 1: any
let config: any = loadConfig();

// Stage 2: partial type information
interface PartialConfig {
  apiKey: string;
  [key: string]: unknown;
}
let config = loadConfig() as PartialConfig;

// Stage 3: complete type information
interface CompleteConfig {
  apiKey: string;
  timeout: number;
  retries: number;
  debug: boolean;
}
let config = loadConfig() as CompleteConfig;

Third-party Libraries

Working with third-party libraries in TypeScript requires type definitions. Many libraries have community-maintained definitions available:

# Installing type definitions for popular libraries
npm install @types/lodash --save-dev
npm install @types/react --save-dev
npm install @types/node --save-dev

Creating custom type definitions when official ones aren’t available:

// custom-library.d.ts
declare module 'custom-library' {
  export function someFunction(param: string): Promise<void>;
  
  export interface SomeType {
    property: string;
    count: number;
    active: boolean;
  }
  
  // Class definition
  export class ApiClient {
    constructor(apiKey: string);
    getData(): Promise<Record<string, unknown>>;
    sendData(data: unknown): Promise<boolean>;
  }
  
  // Default export
  export default function initialize(): void;
}

// Usage in your code
import customLib from 'custom-library';
import { ApiClient } from 'custom-library';

customLib(); // Initialize
const client = new ApiClient('my-api-key');

Complex JavaScript Patterns

TypeScript can handle even complex JavaScript patterns with the right approach:

// Dynamic property access
type DynamicProperty = string | number | boolean | object;

interface DynamicObject {
  [key: string]: DynamicProperty;
}

const config: DynamicObject = {
  apiKey: 'abc123',
  timeout: 5000,
  enabled: true,
  nested: { foo: 'bar' }
};

// Function that takes any property
function getProp<T extends DynamicObject, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const timeout = getProp(config, 'timeout'); // Inferred as number

// Handling mixins
type Constructor<T = {}> = new (...args: any[]) => T;

// Mixin function
function Timestamped<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    timestamp = new Date();
    
    getTimestamp(): Date {
      return this.timestamp;
    }
  };
}

// Base class
class User {
  constructor(public name: string) {}
}

// Apply mixin
const TimestampedUser = Timestamped(User);
const user = new TimestampedUser('John');
console.log(user.getTimestamp());

Best Practices and Tips

Gradual Migration Strategy

Moving a project from JavaScript to TypeScript should be a gradual process. Here’s a proven approach:

  1. Start with newer or simpler files
  2. Enable allowJs in tsconfig.json
  3. Convert files incrementally
  4. Add types gradually
  5. Enable strict mode last
// tsconfig.json for gradual migration
{
  "compilerOptions": {
    "allowJs": true,          // Allow JavaScript files
    "checkJs": true,          // Type check JavaScript files
    "noImplicitAny": false,   // Don't require explicit any types initially
    "strictNullChecks": false // Enable later when ready
  }
}
// Example of progressive typing
// Stage 1: Minimal types
function processUser(user) {
  console.log(user.name);
}

// Stage 2: Basic interface
interface BasicUser {
  name: string;
  [key: string]: any; // Allow any additional properties
}

function processUser(user: BasicUser) {
  console.log(user.name);
}

// Stage 3: Complete interface
interface CompleteUser {
  id: number;
  name: string;
  email: string;
  isActive: boolean;
  roles: string[];
}

function processUser(user: CompleteUser) {
  console.log(`${user.name} (${user.email})`);
}

Type Definition Best Practices

Writing good type definitions is key to getting the most out of TypeScript:

// Use interfaces for object shapes
interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
  inStock: boolean;
}

// Use type aliases for unions and complex types
type Status = 'pending' | 'active' | 'inactive';

type ApiResponse<T> = {
  data: T;
  status: number;
  message: string;
  timestamp: Date;
};

// Utility types for common patterns
type Nullable<T> = T | null;

type ReadOnly<T> = {
  readonly [P in keyof T]: T[P];
};

// Mapped types for transforming interfaces
type Optional<T> = {
  [P in keyof T]?: T[P];
};

type PartialProduct = Optional<Product>;

// Conditional types for advanced scenarios
type ExtractId<T> = T extends { id: infer U } ? U : never;

type ProductId = ExtractId<Product>; // number

// Branded types for type safety
type UserId = number & { readonly brand: unique symbol };

function createUserId(id: number): UserId {
  return id as UserId;
}

function getUserById(id: UserId) {
  // Implementation
}

// Now we can't accidentally pass any number
const userId = createUserId(123);
getUserById(userId); // OK
// getUserById(456); // Error: Argument of type 'number' is not assignable to parameter of type 'UserId'

Common Pitfalls to Avoid

  • Don’t overuse type assertions (as)
  • Avoid @ts-ignore comments
  • Don’t ignore compiler warnings
  • Resist the urge to type everything explicitly
// Avoid this:
function getData(): any {
  // @ts-ignore
  return fetchData();
}

// Better approach:
interface DataResponse {
  items: unknown[];
  total: number;
}

async function getData(): Promise<DataResponse> {
  try {
    const response = await fetchData();
    return {
      items: Array.isArray(response.items) ? response.items : [],
      total: typeof response.total === 'number' ? response.total : 0
    };
  } catch (error) {
    console.error("Error fetching data:", error);
    return { items: [], total: 0 };
  }
}

Advanced Topics

Decorators

Decorators provide a way to add annotations and metadata to classes and class members. They’re commonly used in frameworks like Angular and NestJS:

// Enable experimental decorators in tsconfig.json:
// {
//   "compilerOptions": {
//     "experimentalDecorators": true,
//     "emitDecoratorMetadata": true
//   }
// }

// Method decorator
function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  // Save original method
  const originalMethod = descriptor.value;
  
  // Replace method with wrapped version
  descriptor.value = function(...args: any[]) {
    console.log(`[LOG] Calling ${propertyKey} with arguments: ${JSON.stringify(args)}`);
    
    // Call original method and get result
    const result = originalMethod.apply(this, args);
    
    console.log(`[LOG] Method ${propertyKey} returned: ${JSON.stringify(result)}`);
    return result;
  };
  
  return descriptor;
}

// Property decorator
function required(target: any, propertyKey: string) {
  let value: any;
  
  const getter = function() {
    return value;
  };
  
  const setter = function(newVal: any) {
    if (newVal === undefined || newVal === null) {
      throw new Error(`Property ${propertyKey} cannot be null or undefined`);
    }
    value = newVal;
  };
  
  Object.defineProperty(target, propertyKey, {
    get: getter,
    set: setter,
    enumerable: true,
    configurable: true
  });
}

// Class decorator
function singleton<T extends { new(...args: any[]): {} }>(constructor: T) {
  // Create a new class that extends the original
  return class extends constructor {
    static instance: any;
    
    constructor(...args: any[]) {
      // If instance exists, return it
      if (this.constructor.hasOwnProperty('instance')) {
        return this.constructor['instance'];
      }
      
      // Otherwise create a new instance
      super(...args);
      this.constructor['instance'] = this;
    }
  };
}

// Using the decorators
@singleton
class Calculator {
  @required
  private precision: number;
  
  constructor(precision: number) {
    this.precision = precision;
  }
  
  @log
  add(a: number, b: number): number {
    return +(a + b).toFixed(this.precision);
  }
  
  @log
  multiply(a: number, b: number): number {
    return +(a * b).toFixed(this.precision);
  }
}

// Test the singleton decorator
const calc1 = new Calculator(2);
const calc2 = new Calculator(4);
console.log(calc1 === calc2); // true - same instance

Module Systems

TypeScript supports different module systems, allowing you to organize and share code effectively:

// ES Modules (recommended)
// user.ts
export interface User {
  id: number;
  name: string;
}

export function createUser(name: string): User {
  return { id: Date.now(), name };
}

// Default export
export default class UserService {
  getUsers(): User[] {
    return [createUser('John'), createUser('Jane')];
  }
}

// Importing ES modules
import UserService, { User, createUser } from './user';

// CommonJS equivalent
// user.ts
interface User {
  id: number;
  name: string;
}

function createUser(name: string): User {
  return { id: Date.now(), name };
}

class UserService {
  getUsers(): User[] {
    return [createUser('John'), createUser('Jane')];
  }
}

module.exports = { User, createUser, UserService };

// Importing CommonJS modules
const { User, createUser, UserService } = require('./user');

// Dynamic imports (code splitting)
async function loadUserModule() {
  const { createUser } = await import('./user');
  return createUser('Dynamically loaded');
}

// Barrel files for organizing exports
// users/index.ts
export * from './user';
export * from './user-service';
export * from './user-repository';

// Using barrel exports
import { User, createUser, UserRepository } from './users';

Testing and Quality Assurance

Unit Testing in TypeScript

TypeScript’s type system can help make your tests more robust and maintainable:

// Using Jest with TypeScript
import { createUser } from './user';
import { User } from './types';

describe('User Creation', () => {
  test('should create user with correct properties', () => {
    const user = createUser('John');
    
    // Type checking ensures we're testing the right properties
    expect(user.name).toBe('John');
    expect(typeof user.id).toBe('number');
    
    // TypeScript helps prevent typos in property names
    // expect(user.nmae).toBe('John'); // Error: Property 'nmae' does not exist on type 'User'
  });
  
  test('should throw error for empty name', () => {
    // TypeScript ensures we're calling functions with the right arguments
    expect(() => createUser('')).toThrow();
  });
});

// Type-safe mocks
interface UserRepository {
  findById(id: number): Promise<User | null>;
  save(user: User): Promise<void>;
}

// Mock implementation with type safety
const mockUserRepository: jest.Mocked<UserRepository> = {
  findById: jest.fn(),
  save: jest.fn()
};

test('findById returns user when found', async () => {
  const testUser: User = { id: 1, name: 'Test User' };
  
  // Setup mock return value with correct type
  mockUserRepository.findById.mockResolvedValue(testUser);
  
  const result = await mockUserRepository.findById(1);
  expect(result).toEqual(testUser);
});

Code Quality Tools

ESLint and Prettier can be configured to enforce code quality standards in TypeScript projects:

// .eslintrc.json
{
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:@typescript-eslint/strict"
  ],
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "rules": {
    "@typescript-eslint/explicit-function-return-type": ["error", {
      "allowExpressions": true,
      "allowTypedFunctionExpressions": true
    }],
    "@typescript-eslint/no-explicit-any": "warn",
    "@typescript-eslint/no-unused-vars": ["error", {
      "argsIgnorePattern": "^_",
      "varsIgnorePattern": "^_"
    }],
    "@typescript-eslint/consistent-type-definitions": ["error", "interface"],
    "@typescript-eslint/prefer-optional-chain": "error"
  }
}

// .prettierrc
{
  "semi": true,
  "trailingComma": "es5",
  "singleQuote": true,
  "printWidth": 80,
  "tabWidth": 2
}

// package.json scripts
{
  "scripts": {
    "lint": "eslint 'src/**/*.{ts,tsx}'",
    "lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix",
    "format": "prettier --write 'src/**/*.{ts,tsx}'"
  }
}

Performance Considerations

Build Process Optimization

Optimize your TypeScript build process for both development and production:

// tsconfig.json optimizations
{
  "compilerOptions": {
    // Enable incremental compilation for faster builds
    "incremental": true,
    "tsBuildInfoFile": "./buildcache/tsbuildinfo",
    
    // Parallel type checking
    "assumeChangesOnlyAffectDirectDependencies": true,
    
    // Skip type checking of declaration files
    "skipLibCheck": true,
    
    // Faster module resolution
    "moduleResolution": "node",
    
    // Target modern browsers for smaller output
    "target": "es2018"
  }
}
# Optimize build scripts in package.json
{
  "scripts": {
    "build": "tsc",
    "build:watch": "tsc --watch",
    "build:prod": "tsc --project tsconfig.prod.json && webpack --mode production",
    "typecheck": "tsc --noEmit"
  }
}

Runtime Performance

TypeScript itself adds no runtime overhead, but understanding how types affect the emitted JavaScript can help you write more efficient code:

// Use const assertions for better optimization
const config = {
  api: {
    url: 'https://api.example.com',
    timeout: 5000,
    retries: 3
  }
} as const;

// Use enums carefully - they generate runtime code
// String enums generate less code than numeric enums

// Consider tagged unions for state management
type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success', data: unknown }
  | { status: 'error', error: Error };

// Bundle size optimization
// Use path aliases to avoid deep imports
// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@app/*": ["src/app/*"],
      "@core/*": ["src/core/*"]
    }
  }
}

Conclusion

TypeScript offers a powerful way to enhance your JavaScript development experience. Key takeaways:

  • Start with basic types and gradually increase complexity
  • Use the compiler as your ally in catching errors
  • Take advantage of IDE support
  • Migrate incrementally at your own pace

You May Also Like