DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

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

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:

  • Readable imports: import { User } from '@shared/types' instead of relative hell
  • IDE intellisense: Your editor knows what you're importing
  • Refactoring safety: Move a file, IDE updates imports automatically
  • Monorepo clarity: It's obvious code is coming from a shared package, not adjacent directories

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)
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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:

  • Import @shared/types → resolve to packages/types/src
  • Import @shared/constants → resolve to packages/constants/src

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" }]
}
Enter fullscreen mode Exit fullscreen mode

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" }]
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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/*"]
}
Enter fullscreen mode Exit fullscreen mode

And in each app's package.json:

{
  "dependencies": {
    "@shared/types": "workspace:*"
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)