TypeScript

Monorepo & Shared Types

33 min Lesson 35 of 40

Monorepo & Shared Types

Modern development teams increasingly adopt monorepo architectures to manage multiple related projects in a single repository. TypeScript provides powerful tools for sharing types, configurations, and code across packages while maintaining strict type safety and enabling incremental builds. In this lesson, we'll explore TypeScript project references, building shared type packages, configuring path aliases in monorepos, and best practices for structuring large-scale TypeScript projects.

Why Use TypeScript in Monorepos?

TypeScript in monorepos provides unique advantages:

  • Shared Types: Define API contracts, domain models, and utility types once and use them across all packages
  • Type Safety Across Boundaries: Ensure type consistency between frontend, backend, and mobile apps
  • Incremental Builds: Build only changed packages using project references
  • Refactoring at Scale: Rename types and functions across multiple packages with confidence
  • Unified Tooling: Share TypeScript configurations, linting rules, and build scripts

Monorepo Structure

A typical TypeScript monorepo might look like this:

Monorepo Directory Structure:
my-monorepo/
├── packages/
│   ├── shared-types/          # Shared TypeScript types
│   │   ├── src/
│   │   │   ├── api/           # API request/response types
│   │   │   ├── models/        # Domain models
│   │   │   ├── utils/         # Utility types
│   │   │   └── index.ts       # Main export
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── web-app/               # Frontend application
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── api-server/            # Backend API
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── mobile-app/            # Mobile application
│       ├── src/
│       ├── package.json
│       └── tsconfig.json
├── tsconfig.base.json         # Base TypeScript config
├── package.json               # Root package.json
└── pnpm-workspace.yaml        # Workspace configuration (or lerna.json, etc.)

TypeScript Project References

Project references enable TypeScript to build multiple packages in the correct order and enable incremental compilation:

Base Config (tsconfig.base.json):
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "composite": true,
    "incremental": true
  }
}
Note: The composite: true option is required when using project references. It enables TypeScript to generate .d.ts declaration files and .tsbuildinfo for incremental builds.
Shared Types Package (packages/shared-types/tsconfig.json):
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "composite": true,
    "declaration": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
Consumer Package (packages/web-app/tsconfig.json):
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "jsx": "react-jsx",
    "baseUrl": ".",
    "paths": {
      "@shared-types/*": ["../shared-types/src/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"],
  "references": [
    { "path": "../shared-types" }
  ]
}

Building a Shared Types Package

Create reusable type definitions that can be consumed by multiple packages:

API Types (packages/shared-types/src/api/index.ts):
// Base API types
export interface ApiResponse<T> {
  data: T;
  message?: string;
  success: boolean;
}

export interface PaginatedResponse<T> extends ApiResponse<T[]> {
  meta: {
    total: number;
    page: number;
    perPage: number;
    totalPages: number;
  };
  links: {
    first: string;
    last: string;
    prev: string | null;
    next: string | null;
  };
}

export interface ApiError {
  message: string;
  errors?: Record<string, string[]>;
  statusCode: number;
  timestamp: string;
}

// User API types
export interface LoginRequest {
  email: string;
  password: string;
}

export interface LoginResponse {
  token: string;
  refreshToken: string;
  expiresIn: number;
  user: User;
}

export interface RegisterRequest {
  email: string;
  username: string;
  password: string;
  firstName: string;
  lastName: string;
}
Domain Models (packages/shared-types/src/models/User.ts):
export interface User {
  id: number;
  email: string;
  username: string;
  firstName: string;
  lastName: string;
  avatar?: string;
  role: UserRole;
  createdAt: string;
  updatedAt: string;
}

export enum UserRole {
  ADMIN = 'admin',
  USER = 'user',
  MODERATOR = 'moderator',
  GUEST = 'guest',
}

export interface UserProfile extends User {
  bio?: string;
  website?: string;
  location?: string;
  social?: {
    twitter?: string;
    github?: string;
    linkedin?: string;
  };
}

export interface UserSettings {
  userId: number;
  theme: 'light' | 'dark' | 'auto';
  language: string;
  notifications: {
    email: boolean;
    push: boolean;
    sms: boolean;
  };
  privacy: {
    profileVisibility: 'public' | 'private' | 'friends';
    showEmail: boolean;
    showLocation: boolean;
  };
}
Main Export (packages/shared-types/src/index.ts):
// Export all API types
export * from './api';

// Export all models
export * from './models/User';
export * from './models/Post';
export * from './models/Comment';

// Export utility types
export * from './utils/types';

Path Aliases in Monorepos

Configure path aliases for cleaner imports across your monorepo:

Root tsconfig.json with Path Mapping:
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@shared-types/*": ["packages/shared-types/src/*"],
      "@web-app/*": ["packages/web-app/src/*"],
      "@api-server/*": ["packages/api-server/src/*"],
      "@mobile-app/*": ["packages/mobile-app/src/*"]
    }
  }
}
Using Path Aliases:
// Instead of relative imports:
// import { User } from '../../../shared-types/src/models/User';

// Use clean path aliases:
import { User, UserRole, LoginRequest } from '@shared-types/models/User';
import { ApiResponse, PaginatedResponse } from '@shared-types/api';

// In your application code
async function fetchUser(id: number): Promise<ApiResponse<User>> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}
Tip: When using path aliases, ensure your bundler (Webpack, Vite, etc.) or runtime (Node.js with ts-node) is also configured to resolve these aliases. Tools like tsconfig-paths can help with Node.js runtime resolution.

Building with Project References

Use TypeScript's build mode to compile projects in dependency order:

Build Commands:
# Build all referenced projects
tsc --build

# Build specific project and its dependencies
tsc --build packages/web-app

# Clean build outputs
tsc --build --clean

# Force rebuild
tsc --build --force

# Watch mode for development
tsc --build --watch
Package Scripts (packages/web-app/package.json):
{
  "name": "@mycompany/web-app",
  "version": "1.0.0",
  "scripts": {
    "build": "tsc --build",
    "build:watch": "tsc --build --watch",
    "clean": "tsc --build --clean",
    "type-check": "tsc --noEmit"
  },
  "dependencies": {
    "@mycompany/shared-types": "workspace:*"
  }
}

Workspace Package Management

Configure your package manager for monorepo support:

pnpm Workspace (pnpm-workspace.yaml):
packages:
  - 'packages/*'
Yarn Workspaces (package.json):
{
  "private": true,
  "workspaces": [
    "packages/*"
  ]
}
npm Workspaces (package.json):
{
  "private": true,
  "workspaces": [
    "packages/*"
  ]
}

Type Versioning and Compatibility

Manage breaking changes in shared types across packages:

Versioned API Types:
// packages/shared-types/src/api/v1/User.ts
export namespace UserApiV1 {
  export interface User {
    id: number;
    name: string;
    email: string;
  }

  export interface CreateUserRequest {
    name: string;
    email: string;
    password: string;
  }
}

// packages/shared-types/src/api/v2/User.ts
export namespace UserApiV2 {
  export interface User {
    id: number;
    firstName: string; // Breaking change: split name
    lastName: string;
    email: string;
    username: string; // New field
  }

  export interface CreateUserRequest {
    firstName: string;
    lastName: string;
    username: string;
    email: string;
    password: string;
  }
}

// Consumers can import specific versions
import { UserApiV1 } from '@shared-types/api/v1/User';
import { UserApiV2 } from '@shared-types/api/v2/User';
Warning: When making breaking changes to shared types, consider versioning your APIs to prevent disruption to existing consumers. Use semantic versioning for your shared-types package to communicate breaking changes.

Type-Safe RPC/API Contracts

Define end-to-end type-safe API contracts using shared types:

API Contract Definition:
// packages/shared-types/src/contracts/user.contract.ts
export interface UserContract {
  // Endpoint definitions with request/response types
  'GET /users': {
    request: {
      query: {
        page?: number;
        perPage?: number;
        search?: string;
      };
    };
    response: PaginatedResponse<User>;
  };

  'GET /users/:id': {
    request: {
      params: {
        id: number;
      };
    };
    response: ApiResponse<User>;
  };

  'POST /users': {
    request: {
      body: RegisterRequest;
    };
    response: ApiResponse<User>;
  };

  'PUT /users/:id': {
    request: {
      params: {
        id: number;
      };
      body: Partial<User>;
    };
    response: ApiResponse<User>;
  };

  'DELETE /users/:id': {
    request: {
      params: {
        id: number;
      };
    };
    response: ApiResponse<{ deleted: boolean }>;
  };
}

// Type-safe API client
type ApiClient<Contract> = {
  [K in keyof Contract]: (
    request: Contract[K]['request']
  ) => Promise<Contract[K]['response']>;
};

// Usage in frontend
const userApi: ApiClient<UserContract> = {
  'GET /users': async (req) => {
    const { page, perPage, search } = req.query;
    const response = await fetch(
      `/api/users?page=${page}&perPage=${perPage}&search=${search}`
    );
    return response.json();
  },
  // ... other endpoints
};

Shared Configuration Files

Centralize ESLint, Prettier, and other tool configurations:

Shared ESLint Config (packages/eslint-config/index.js):
module.exports = {
  parser: '@typescript-eslint/parser',
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:prettier/recommended',
  ],
  rules: {
    '@typescript-eslint/explicit-function-return-type': 'warn',
    '@typescript-eslint/no-explicit-any': 'error',
    '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
  },
};
Consuming Shared Config (packages/web-app/.eslintrc.js):
module.exports = {
  extends: ['@mycompany/eslint-config'],
  rules: {
    // Project-specific overrides
  },
};

Best Practices for Monorepo Types

  • Single Source of Truth: Define domain models once in shared-types, never duplicate
  • Versioning: Use semantic versioning for shared-types package; communicate breaking changes
  • Namespaces: Use TypeScript namespaces or folder structure to organize types by domain
  • Documentation: Add JSDoc comments to shared types for better IDE tooltips
  • Validation: Consider runtime validation libraries (Zod, io-ts) for API boundaries
  • Build Order: Leverage project references for correct build order and incremental builds
  • Testing: Write tests for shared types using type-level testing libraries like tsd
Exercise:
  1. Set up a monorepo with 3 packages: shared-types, web-app, and api-server
  2. Configure TypeScript project references between packages
  3. Create shared type definitions for User, Post, and Comment models
  4. Define API contract types for CRUD operations on these models
  5. Set up path aliases for clean imports across packages
  6. Implement a type-safe API client in web-app that uses the shared contracts
  7. Configure incremental builds and test that only changed packages rebuild

Summary

In this lesson, you learned how to structure TypeScript projects in a monorepo architecture using project references, shared type packages, and path aliases. You explored how to build reusable type definitions that ensure type consistency across frontend, backend, and mobile applications. You also learned about versioning strategies for shared types, type-safe API contracts, and best practices for managing large-scale TypeScript codebases. These techniques enable teams to work on multiple related projects with confidence, knowing that TypeScript will catch cross-package type errors at compile time rather than in production.