Organizing types and the @ path alias in TypeScript
Table of Contents
- Why separate types into their own files
- Folder structures that work
- File naming conventions
- interface vs type — when to use which
- import type — avoid runtime overhead
- Barrel exports for clean imports
- The @ path alias — what it is and why
- Setting up @ in a Vite project
- Putting it all together
Why separate types into their own files
A User type might be needed in API calls, React components, services, and tests.
Instead of redefining it everywhere, define it once and import it:
// types/user.types.ts
export interface User {
id: number
name: string
email: string
}
// anywhere else
import type { User } from '@/types'
function createUser(user: User) {}
Without separation, you end up with inline types that are hard to read and impossible to reuse:
// ❌ inline — repeated everywhere, hard to maintain
function createUser(user: { id: number; name: string; email: string }) {}
Shared type files also prevent the classic team bug: developer A returns username,
developer B expects name. A single shared type catches this at compile time.
Folder structures that work
Global types folder (most common)
Best for small-to-medium projects:
src/
├── types/
│ ├── user.types.ts
│ ├── order.types.ts
│ ├── api.types.ts
│ └── index.ts ← barrel export
├── components/
├── services/
└── utils/
Feature-based (large projects)
Types live next to the code that uses them:
src/
├── features/
│ ├── auth/
│ │ ├── auth.types.ts
│ │ ├── auth.service.ts
│ │ └── auth.controller.ts
│ └── users/
│ ├── user.types.ts
│ ├── user.service.ts
│ └── user.controller.ts
This keeps everything related together. Larger codebases tend to prefer this because it scales better — each feature is self-contained.
Which to pick?
Start with the global types/ folder. Move to feature-based when you have 10+ type files
and they're clearly tied to specific features.
File naming conventions
Three common patterns — pick one and be consistent:
| Pattern | Example | When |
|---|---|---|
*.types.ts | user.types.ts | Inside any folder — explicit and searchable |
*.ts inside /types | types/user.ts | When the folder already says "types" |
types.ts per module | auth/types.ts | Feature-based architecture |
Most teams use user.types.ts because it's unambiguous no matter where the file sits.
interface vs type — when to use which
Use interface for object shapes:
export interface User {
id: number
name: string
email: string
}
Use type for unions, intersections, and utility types:
export type Status = 'pending' | 'paid' | 'failed'
export type ID = string | number
Interfaces support declaration merging and extends. Types support unions and mapped types.
For most object shapes, either works — just be consistent within a project.
import type — avoid runtime overhead
When you only need a type (not a value), use import type:
import type { User } from '@/types'
This tells the compiler the import is erased at build time — it never appears in the compiled JavaScript output. Benefits:
- Smaller bundles — no leftover import statements for things that don't exist at runtime
- Clearer intent — anyone reading the code knows this is a type, not a runtime value
- Avoids circular dependency issues in some bundler configurations
With verbatimModuleSyntax: true in your tsconfig.app.json (Vite's default),
TypeScript requires import type for type-only imports — so this isn't optional.
Barrel exports for clean imports
Suppose this is your structure:
src/
├── types/
│ ├── user.types.ts
│ ├── order.types.ts
│ ├── api.types.ts
│ └── index.ts ← barrel export
├── components/
├── services/
└── utils/
Create an index.ts file in your types folder that re-exports everything:
// types/index.ts
export * from './user.types'
export * from './order.types'
export * from './api.types'
Now imports become shorter:
// ✅ clean — one import source
import type { User, Order } from '@/types'
// ❌ without barrel — separate imports
import type { User } from '@/types/user.types'
import type { Order } from '@/types/order.types'
This also works for components:
// components/index.ts
export { Button } from './Button'
export { Card } from './Card'
// usage
import { Button, Card } from '@/components'
The @ path alias — what it is and why
In many TypeScript projects you see:
import type { User } from '@/types/user'
@ is a path alias that maps to src/. It's not built into TypeScript or JavaScript —
you configure it yourself.
Without it, imports from deeply nested files look like this:
import type { User } from '../../../types/user'
These relative paths are fragile (they break when you move files) and hard to read.
With @:
import type { User } from '@/types/user'
Always resolves from src/, no matter where the importing file lives.
Setting up @ in a Vite project
You must configure two places — Vite (for the bundler) and TypeScript (for the type checker). If you only do one, the other won't understand the alias.
Step 1 — Vite config
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
This tells Vite: when you see @/..., resolve it from src/.
Step 2 — TypeScript config
Add baseUrl and paths to tsconfig.app.json:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
This tells the TypeScript compiler (and VS Code's language server) how to resolve @/....
Common mistake
Configuring only Vite but forgetting the TypeScript paths:
Cannot find module '@/components/Button'
The app runs fine (Vite resolves it), but VS Code shows errors because TypeScript
doesn't know what @ means. Always configure both.
Stick to one alias
Some teams create multiple aliases (@components, @services, @types).
This adds complexity without much benefit. One alias (@ → src) is enough
for most projects.
Putting it all together
A clean Vite + React + TypeScript project structure:
src/
├── types/
│ ├── user.types.ts
│ ├── api.types.ts
│ └── index.ts
├── services/
│ └── user.service.ts
├── components/
│ ├── UserCard.tsx
│ └── index.ts
└── pages/
└── Home.tsx
Type file:
// types/user.types.ts
export interface User {
id: number
name: string
email: string
}
Barrel export:
// types/index.ts
export * from './user.types'
export * from './api.types'
Service using the type:
// services/user.service.ts
import type { User } from '@/types'
export async function getUsers(): Promise<User[]> {
const res = await fetch('/api/users')
return res.json()
}
Component using both:
// components/UserCard.tsx
import type { User } from '@/types'
export function UserCard({ user }: { user: User }) {
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)
}
The pattern: define types once → barrel export → import with import type and @/ everywhere.