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:
- Relative imports:
import { User } from '../../../packages/types' - Published npm packages: Overkill for internal monorepo code
- 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)
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:
- Import
@shared/types→ resolve topackages/types/src - Import
@shared/constants→ resolve topackages/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" }]
}
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.