Monorepo & Shared Types
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:
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:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"composite": true,
"incremental": true
}
}
composite: true option is required when using project references. It enables TypeScript to generate .d.ts declaration files and .tsbuildinfo for incremental builds.
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"composite": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
{
"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:
// 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;
}
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;
};
}
// 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:
{
"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/*"]
}
}
}
// 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();
}
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 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
{
"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:
packages: - 'packages/*'
{
"private": true,
"workspaces": [
"packages/*"
]
}
{
"private": true,
"workspaces": [
"packages/*"
]
}
Type Versioning and Compatibility
Manage breaking changes in shared types across packages:
// 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';
Type-Safe RPC/API Contracts
Define end-to-end type-safe API contracts using shared types:
// 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:
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: '^_' }],
},
};
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
- Set up a monorepo with 3 packages: shared-types, web-app, and api-server
- Configure TypeScript project references between packages
- Create shared type definitions for User, Post, and Comment models
- Define API contract types for CRUD operations on these models
- Set up path aliases for clean imports across packages
- Implement a type-safe API client in web-app that uses the shared contracts
- 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.