Skip to main content
All posts

TypeScript Path Aliases Across Monorepo Workspaces

7 June 2026

TypeScript Path Aliases Across Monorepo Workspaces: Configuring tsconfig So Your Astro, React, and FastAPI Projects Share Types Without Import Hell

Monorepos are great until they’re not. I’ve been burned by relative import chains more times than I’d like to admit. You’re three directories deep in your React dashboard, you need a User type from your shared package, and you’re staring at ../../../packages/types/src/user.ts. Then someone reorganizes the folder structure, and everything breaks.

I’m here to tell you: this problem is solvable with proper TypeScript path alias configuration. I’ve set this up across CitizenApp’s stack—Astro marketing site, React 19 dashboard, FastAPI backend, and shared types package—and I’m going to show you exactly what works.

Why Path Aliases Matter in Monorepos

When you’re working with multiple workspaces that need to share types, you have three options:

  1. Relative imports: import { User } from '../../../packages/types'
  2. Published npm packages: Overkill for internal monorepo code
  3. TypeScript path aliases: The Goldilocks solution

Path aliases give you:

The catch? TypeScript doesn’t magically know where @shared/types lives across workspace boundaries. You need to configure it properly.

The Monorepo Structure I Use

Here’s what CitizenApp looks like:

monorepo/
├── packages/
│   └── types/
│       ├── package.json
│       └── src/
│           ├── user.ts
│           ├── tenant.ts
│           └── index.ts
├── apps/
│   ├── web/
│   │   ├── package.json (Astro)
│   │   └── tsconfig.json
│   ├── dashboard/
│   │   ├── package.json (React 19)
│   │   └── tsconfig.json
│   └── api/
│       └── (FastAPI—more on this later)
├── package.json (root)
└── tsconfig.json (root)

This structure is intentional. Shared types live in packages/types. Applications reference it via path aliases.

Step 1: Configure the Shared Types Package

First, your packages/types/tsconfig.json should be minimal but explicit:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "bundler"
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

And packages/types/package.json:

{
  "name": "@shared/types",
  "version": "1.0.0",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist"]
}

The exports field is crucial. It tells consumers exactly where to find types. No ambiguity.

Step 2: Root tsconfig.json with Workspace References

Here’s your root tsconfig.json—this ties everything together:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "baseUrl": ".",
    "paths": {
      "@shared/*": ["packages/*/src"]
    }
  },
  "ts.Node": {
    "transpileOnly": true,
    "compilerOptions": {
      "module": "commonjs"
    }
  },
  "references": [
    { "path": "packages/types" },
    { "path": "apps/web" },
    { "path": "apps/dashboard" }
  ]
}

Why "references"? They tell TypeScript these are separate compilation units. Each workspace compiles independently, but they know about each other. This prevents circular dependencies and keeps incremental builds fast.

Why "baseUrl" and "paths"? This is where the magic happens. "@shared/*": ["packages/*/src"] means:

Step 3: Application-Level tsconfig Files

Now, each application extends the root config with its own adjustments.

React Dashboard (apps/dashboard/tsconfig.json):

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true
  },
  "include": ["src"],
  "references": [{ "path": "../../packages/types" }]
}

Astro Site (apps/web/tsconfig.json):

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "jsx": "preserve",
    "moduleResolution": "bundler"
  },
  "include": ["src"],
  "references": [{ "path": "../../packages/types" }]
}

Each extends the root, inheriting path aliases. The "references" field in each app ensures TypeScript knows about the types package.

Using It: The Actual Imports

Now you can import in your React component:

// apps/dashboard/src/components/UserCard.tsx
import { User, Tenant } from '@shared/types';

interface Props {
  user: User;
  tenant: Tenant;
}

export function UserCard({ user, tenant }: Props) {
  return (
    <div>
      <h2>{user.email}</h2>
      <p>Tenant: {tenant.name}</p>
    </div>
  );
}

In your Astro page:

// apps/web/src/pages/index.astro
---
import { User } from '@shared/types';

const user: User = {
  id: '123',
  email: 'user@example.com',
};
---

<h1>Welcome, {user.email}</h1>

No ../../../ chains. No duplicate type definitions. Your IDE autocompletes. Builds work.

What About FastAPI?

I’ll be direct: Python doesn’t use TypeScript path aliases. But here’s what I do: I generate Python types from my TypeScript definitions using datamodel-code-generator or pydantic schema exports. The shared source of truth remains the TypeScript types package, and Python consumes generated .py files.

Keep the types in one place, generate outward.

Gotcha: Build Order and Workspace Dependencies

This burned me: if your applications import from @shared/types, make sure packages/types is compiled first. With Turborepo or pnpm workspaces, add this to your root package.json:

{
  "workspaces": ["packages/*", "apps/*"]
}

And in each app’s package.json:

{
  "dependencies": {
    "@shared/types": "workspace:*"
  }
}

The workspace:* protocol tells the monorepo tool to link locally, not fetch from npm. Without this, your builds fail silently with “cannot find module” errors.

Another Gotcha: moduleResolution Must Match

I spent two hours debugging why Vercel builds worked but local builds didn’t. The issue? My local tsconfig used "nodeNext" while my build system used "bundler". TypeScript resolved paths differently.

Use "bundler" for modern monorepos. It’s more predictable.

The Real Win

Once this is set up, your team stops fighting import paths. You can refactor the monorepo structure without updating hundreds of import statements. New team members see @shared/types and instantly understand “oh, that’s shared code.”

It’s a small thing that compounds into massive productivity gains. Worth the 30 minutes to configure correctly.

You might also like

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch