Introduction to Turbopack with TypeScript
Turbopack is a next-generation JavaScript and TypeScript bundler developed by Vercel, designed as a Rust-based successor to Webpack. When combined with TypeScript, Turbopack offers a powerful development experience that enables strongly typed applications with blazing-fast build times. This tutorial will guide you through setting up, configuring, and optimizing a Turbopack-powered TypeScript application.
What is Turbopack?
Turbopack is an incremental bundler optimized for large-scale JavaScript and TypeScript applications. Built in Rust by the creators of Next.js and Webpack, it aims to address performance bottlenecks that developers face with traditional bundlers. Turbopack performs incremental computation by caching the results of every function at the function level, which means it can reuse work across builds and even across development sessions.
Key features of Turbopack include:
- Function-level caching for incremental builds
- Native TypeScript support without requiring separate transpilation
- Seamless integration with Next.js
- Significantly faster development server startup times
- Hot Module Replacement (HMR) that scales with large codebases
Why TypeScript with Turbopack Matters
TypeScript provides static type checking that catches errors at compile time rather than runtime. When paired with Turbopack, you get the best of both worlds: type safety and exceptional performance. Traditional bundlers often slow down significantly when processing TypeScript files, especially in large monorepos. Turbopack's Rust-based architecture handles TypeScript parsing and transformation with remarkable speed.
Here are the primary benefits of using TypeScript with Turbopack:
- Faster type-aware development: Turbopack processes TypeScript files natively, reducing the overhead of type checking during development.
- Improved developer experience: Faster builds mean quicker feedback loops, allowing developers to iterate rapidly.
- Scalability: Turbopack's incremental architecture ensures that build times remain manageable as your application grows.
- Type safety across the stack: Strong typing helps prevent common runtime errors and makes refactoring safer.
Setting Up a Turbopack TypeScript Project
Prerequisites
Before you begin, ensure you have the following installed:
- Node.js 18.17 or later
- A package manager (npm, yarn, or pnpm)
- Basic familiarity with TypeScript
Creating a New Next.js Project with Turbopack
The easiest way to get started with Turbopack and TypeScript is through Next.js, which has first-class support for both. Run the following command to create a new project:
npx create-next-app@latest my-turbopack-app --typescript --turbo
This command scaffolds a new Next.js application with TypeScript and Turbopack enabled by default. Once the installation completes, navigate to your project directory:
cd my-turbopack-app
Enabling Turbopack in an Existing Project
If you have an existing Next.js project and want to enable Turbopack, you need to update your package.json scripts. Modify the development script to include the --turbo flag:
{
"scripts": {
"dev": "next dev --turbo",
"build": "next build",
"start": "next start",
"lint": "next lint"
}
}
For standalone Turbopack usage outside of Next.js, you can install the Turbopack CLI directly:
npm install @turbo/gen --save-dev
Configuring TypeScript
Next.js generates a tsconfig.json file automatically. Here is a recommended configuration for a Turbopack TypeScript project:
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
The strict flag enables all strict type checking options, which is essential for strongly typed applications. The moduleResolution set to "bundler" aligns with how Turbopack resolves modules.
Building a Strongly Typed Application
Defining Type-Safe Components
Let us start by creating a type-safe React component. Create a file at src/components/UserCard.tsx:
import React from 'react';
// Define a strongly typed interface for the component props
interface UserCardProps {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
avatarUrl?: string;
onUserClick?: (id: number) => void;
}
const UserCard: React.FC<UserCardProps> = ({
id,
name,
email,
role,
avatarUrl,
onUserClick,
}) => {
const handleClick = () => {
if (onUserClick) {
onUserClick(id);
}
};
return (
<div
className="user-card"
onClick={handleClick}
style={{ cursor: onUserClick ? 'pointer' : 'default' }}
>
{avatarUrl && (
<img src={avatarUrl} alt={name} className="user-avatar" />
)}
<div className="user-info">
<h3>{name}</h3>
<p>{email}</p>
<span className={`role-badge role-${role}`}>{role}</span>
</div>
</div>
);
};
export default UserCard;
Notice how the role property uses a union type ('admin' | 'editor' | 'viewer'). This ensures that only valid role strings can be passed, catching errors at compile time.
Creating Type-Safe API Routes
Strong typing extends beyond components. Let us create a type-safe API route. Create a file at src/app/api/users/route.ts:
import { NextRequest, NextResponse } from 'next/server';
// Define types for the API response
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: string;
}
interface ApiResponse {
success: boolean;
data: User[];
total: number;
}
interface ErrorResponse {
success: false;
error: string;
code: string;
}
// Mock database
const users: User[] = [
{
id: 1,
name: 'Alice Johnson',
email: 'alice@example.com',
role: 'admin',
createdAt: '2024-01-15T10:00:00Z',
},
{
id: 2,
name: 'Bob Smith',
email: 'bob@example.com',
role: 'editor',
createdAt: '2024-02-20T14:30:00Z',
},
];
export async function GET(
request: NextRequest
): Promise<NextResponse<ApiResponse | ErrorResponse>> {
try {
const { searchParams } = new URL(request.url);
const roleFilter = searchParams.get('role');
let filteredUsers: User[] = users;
if (roleFilter) {
// TypeScript ensures roleFilter matches valid roles
const validRoles: User['role'][] = ['admin', 'editor', 'viewer'];
if (validRoles.includes(roleFilter as User['role'])) {
filteredUsers = users.filter((user) => user.role === roleFilter);
}
}
const response: ApiResponse = {
success: true,
data: filteredUsers,
total: filteredUsers.length,
};
return NextResponse.json(response);
} catch (error) {
const errorResponse: ErrorResponse = {
success: false,
error: 'Internal server error',
code: 'INTERNAL_ERROR',
};
return NextResponse.json(errorResponse, { status: 500 });
}
}
Type-Safe Data Fetching
One of the most powerful patterns in a strongly typed application is type-safe data fetching. Create a file at src/lib/api.ts:
// Type definitions for API responses
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
}
interface Post {
id: number;
title: string;
content: string;
authorId: number;
publishedAt: string;
}
// A generic fetch wrapper with type safety
async function fetchApi<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(`/api/${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<T>;
}
// Type-safe API client
export const apiClient = {
getUsers: (): Promise<User[]> => fetchApi<User[]>('users'),
getUserById: (id: number): Promise<User> =>
fetchApi<User>(`users/${id}`),
getPosts: (): Promise<Post[]> => fetchApi<Post[]>('posts'),
getPostsByAuthor: (authorId: number): Promise<Post[]> =>
fetchApi<Post[]>(`posts?authorId=${authorId}`),
createUser: (data: Omit<User, 'id'>): Promise<User> =>
fetchApi<User>('users', {
method: 'POST',
body: JSON.stringify(data),
}),
updateUser: (id: number, data: Partial<Omit<User, 'id'>>): Promise<User> =>
fetchApi<User>(`users/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
deleteUser: (id: number): Promise<{ success: boolean }> =>
fetchApi<{ success: boolean }>(`users/${id}`, {
method: 'DELETE',
}),
};
With this setup, every API call is fully typed. The Omit utility type ensures that createUser does not require an id field, while Partial makes all fields optional for updateUser.
Using the API Client in a Component
Now let us create a component that uses our type-safe API client. Create a file at src/components/UserList.tsx:
'use client';
import React, { useState, useEffect } from 'react';
import { apiClient } from '@/lib/api';
import UserCard from './UserCard';
interface UserListProps {
filterRole?: 'admin' | 'editor' | 'viewer';
onUserSelect?: (userId: number) => void;
}
const UserList: React.FC<UserListProps> = ({ filterRole, onUserSelect }) => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true);
setError(null);
const allUsers = await apiClient.getUsers();
// TypeScript knows that filterRole is a valid role
const filtered = filterRole
? allUsers.filter((user) => user.role === filterRole)
: allUsers;
setUsers(filtered);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchUsers();
}, [filterRole]);
if (loading) {
return <div className="loading">Loading users...</div>;
}
if (error) {
return <div className="error">Error: {error}</div>;
}
if (users.length === 0) {
return <div className="empty">No users found.</div>;
}
return (
<div className="user-list">
{users.map((user) => (
<UserCard
key={user.id}
id={user.id}
name={user.name}
email={user.email}
role={user.role}
onUserClick={onUserSelect}
/>
))}
</div>
);
};
export default UserList;
Note that we need to import the User type. In a real project, you would export it from src/lib/api.ts and import it here. The 'use client' directive is required because this component uses React hooks and client-side fetching.
Advanced Type Patterns
Shared Type Definitions
For larger applications, it is best to centralize your type definitions. Create a file at src/types/index.ts:
// Base entity interface
export interface BaseEntity {
id: number;
createdAt: string;
updatedAt: string;
}
// User-related types
export type UserRole = 'admin' | 'editor' | 'viewer';
export interface User extends BaseEntity {
name: string;
email: string;
role: UserRole;
avatarUrl?: string;
}
export type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
export type UpdateUserInput = Partial<CreateUserInput>;
// Post-related types
export interface Post extends BaseEntity {
title: string;
content: string;
authorId: number;
publishedAt: string | null;
tags: string[];
}
export type CreatePostInput = Omit<Post, 'id' | 'createdAt' | 'updatedAt'>;
export type UpdatePostInput = Partial<CreatePostInput>;
// API response wrapper
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}
export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export interface ApiError {
success: false;
error: string;
code: string;
details?: Record<string, string>;
}
Type-Safe Environment Variables
TypeScript can also enforce that your environment variables are properly configured. Create a file at src/lib/env.ts:
interface EnvConfig {
NEXT_PUBLIC_API_URL: string;
NEXT_PUBLIC_APP_NAME: string;
DATABASE_URL: string;
JWT_SECRET: string;
}
function getEnvVar(key: keyof EnvConfig): string {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
export const env: EnvConfig = {
NEXT_PUBLIC_API_URL: getEnvVar('NEXT_PUBLIC_API_URL'),
NEXT_PUBLIC_APP_NAME: getEnvVar('NEXT_PUBLIC_APP_NAME'),
DATABASE_URL: getEnvVar('DATABASE_URL'),
JWT_SECRET: getEnvVar('JWT_SECRET'),
};
This approach ensures that any missing environment variable causes an immediate, clear error at startup rather than a confusing runtime failure later.
Type-Safe Configuration with Zod
For runtime validation alongside compile-time types, integrate Zod. First, install it:
npm install zod
Then create a validation schema at src/lib/validation.ts:
import { z } from 'zod';
// User validation schema
export const userSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
role: z.enum(['admin', 'editor', 'viewer']),
avatarUrl: z.string().url().optional(),
});
// Infer the TypeScript type from the Zod schema
export type ValidatedUser = z.infer<typeof userSchema>;
// Post validation schema
export const postSchema = z.object({
title: z.string().min(1, 'Title is required').max(200, 'Title is too long'),
content: z.string().min(10, 'Content must be at least 10 characters'),
tags: z.array(z.string()).max(5, 'Maximum 5 tags allowed').optional(),
});
export type ValidatedPost = z.infer<typeof postSchema>;
// Form validation helper
export function validateInput<T>(
schema: z.ZodSchema<T>,
data: unknown
): { success: true; data: T } | { success: false; errors: string[] } {
const result = schema.safeParse(data);
if (result.success) {
return { success: true, data: result.data };
}
const errors = result.error.issues.map(
(issue) => `${issue.path.join('.')}: ${issue.message}`
);
return { success: false, errors };
}
With Zod, you get a single source of truth: the schema defines both the runtime validation rules and the TypeScript types through z.infer.
Turbopack Configuration for TypeScript
Customizing Turbopack in Next.js
Next.js allows you to customize Turbopack's behavior through the next.config.js file. Here is an example configuration:
/** @type {import('next').NextConfig} */
const nextConfig = {
// Enable Turbopack for development
experimental: {
turbo: {
// Custom resolve aliases
resolveAlias: {
// Map imports to specific files
'@/components': './src/components',
'@/lib': './src/lib',
'@/types': './src/types',
'@/hooks': './src/hooks',
'@/utils': './src/utils',
},
// Custom rules for handling specific file types
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
},
},
},
};
module.exports = nextConfig;
Path Aliases and Module Resolution
Turbopack respects the paths configuration in your tsconfig.json. Ensure your aliases are properly set up so that both TypeScript and Turbopack can resolve them:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/types/*": ["./src/types/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/utils/*": ["./src/utils/*"],
"@/styles/*": ["./src/styles/*"]
}
}
}
With these aliases in place, you can import modules cleanly:
import { apiClient } from '@/lib/api';
import { User, Post } from '@/types';
import { validateInput, userSchema } from '@/lib/validation';
import UserCard from '@/components/UserCard';
Best Practices for Turbopack TypeScript Applications
1. Enable Strict Mode Always
Always use "strict": true in your tsconfig.json. This enables a suite of strict type checking options including noImplicitAny, strictNullChecks, strictFunctionTypes, and more. These checks catch subtle bugs that would otherwise surface at runtime.
2. Use Utility Types Effectively
TypeScript provides powerful utility types like Omit, Pick, Partial, Record, and Readonly. Use them to derive new types from existing ones instead of duplicating type definitions:
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
passwordHash: string;
}
// Safe to send to the client - excludes sensitive fields
type PublicUser = Omit<User, 'passwordHash'>;
// For creating a new user - no id or passwordHash needed
type NewUserInput = Omit<User, 'id' | 'passwordHash'>;
// For updates - all fields optional
type UserUpdate = Partial<NewUserInput>;
// A lookup table of users by ID
type UserMap = Record<number, PublicUser>;
// Immutable configuration
type AppConfig = Readonly<{
apiUrl: string;
maxRetries: number;
timeout: number;
}>;
3. Leverage Turbopack's Caching
Turbopack caches function-level results, which means subsequent builds are significantly faster. To maximize this benefit, avoid changing configuration files frequently during development, as configuration changes can invalidate caches. Structure your code so that modules have clear boundaries, allowing Turbopack to cache individual modules effectively.
4. Use Type-Only Imports
When importing only types, use the import type syntax. This helps Turbopack and TypeScript understand that these imports can be safely erased during compilation, reducing bundle size:
// Type-only import - erased at compile time
import type { User, Post, ApiResponse } from '@/types';
// Value import - included in the bundle
import { apiClient } from '@/lib/api';
// Mixed import - separates types from values
import { apiClient } from '@/lib/api';
import type { User } from '@/types';
5. Implement Error Boundaries with Types
Create typed error boundaries to handle errors gracefully in your application:
'use client';
import React from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ComponentType<{ error: Error; reset: () => void }>;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
class TypedErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
}
reset = (): void => {
this.setState({ hasError: false, error: null });
};
render(): React.ReactNode {
if (this.state.hasError && this.state.error) {
if (this.props.fallback) {
const Fallback = this.props.fallback;
return <Fallback error={this.state.error} reset={this.reset} />;
}
return (
<div className="error-boundary">
<h2>Something went wrong</h2>
<p>{this.state.error.message}</p>
<button onClick={this.reset}>Try again</button>
</div>
);
}
return this.props.children;
}
}
export default TypedErrorBoundary;
6. Optimize Type Checking Performance
For large projects, type checking can become slow. Use skipLibCheck to skip type checking of declaration files from node_modules. Additionally, consider using incremental: true to enable incremental type checking, which only re-checks files that have changed:
{
"compilerOptions": {
"skipLibCheck": true,
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo"
}
}
7. Separate Type Checking from Bundling
Turbopack focuses on fast transpilation rather than full type checking. For production builds, run type checking as a separate step. Update your package.json scripts:
{
"scripts": {
"dev": "next dev --turbo",
"build": "tsc --noEmit && next build",
"type-check": "tsc --noEmit",
"start": "next start",
"lint": "next lint"
}
}
This ensures that type errors are caught during the build process without slowing down your development server.
Debugging and Troubleshooting
Common Issues and Solutions
Here are some common issues you might encounter when using Turbopack with TypeScript, along with their solutions:
Issue: Turbopack does not recognize path aliases.
Ensure that your tsconfig.json has both baseUrl and paths configured correctly. Turbopack reads these settings to resolve module paths. Also check that the next.config.js resolveAlias configuration matches your tsconfig.json paths.
Issue: Type errors are not shown during development.
Turbopack transpiles TypeScript without performing full type checking during development. To see type errors, run npx tsc --noEmit in a separate terminal or set up an IDE like VS Code that provides real-time type checking through its TypeScript language server.
Issue: Slow builds despite using Turbopack.
Check for circular dependencies in your codebase, as they can prevent Turbopack from caching effectively. Use tools like madge to detect circular dependencies:
npx madge --circular --extensions ts,tsx src/
Using Turbopack Trace Logging
To debug Turbopack's behavior, you can enable trace logging by setting the TURBOPACK_TRACE environment variable:
TURBOPACK_TRACE=1 npm run dev
This outputs detailed information about what Turbopack is doing, including which modules it is processing and how it is caching results.
Monorepo Setup with Turbopack and TypeScript
For larger projects, a monorepo structure can help organize shared types and utilities. Using Turborepo (a build system that works well with Turbopack), you can set up a strongly typed monorepo:
my-monorepo/
βββ apps/
β βββ web/
β β βββ src/
β β βββ tsconfig.json
β β βββ package.json
β βββ admin/
β βββ src/
β βββ tsconfig.json
β βββ package.json
βββ packages/
β βββ types/
β β βββ src/
β β β βββ index.ts
β β βββ tsconfig.json
β β βββ package.json
β βββ ui/
β β βββ src/
β β βββ tsconfig.json
β β βββ package.json
β βββ utils/
β βββ src/
β βββ tsconfig.json
β βββ package.json
βββ turbo.json
βββ tsconfig.json
βββ package.json
The root tsconfig.json serves as the base configuration:
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"module": "esnext",
"target": "ES2017",
"jsx": "preserve",
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
Each package extends the root configuration. For example, packages/types/tsconfig.json:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
And apps/web/tsconfig.json:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"@myorg/types": ["../../packages/types/src"],
"@myorg/ui": ["../../packages/ui/src"],
"@myorg/utils": ["../../packages/utils/src"]
}
},
"include": ["src/**/*", "next-env.d.ts"]
}
The shared types package at packages/types/src/index.ts exports all shared types:
// packages/types/src/index.ts
export interface BaseEntity {
id: number;
createdAt: string;
updatedAt: string;
}
export type UserRole = 'admin' | 'editor' | 'viewer';
export interface User extends BaseEntity {
name: string;
email: string;
role: UserRole;
}
export interface Post extends BaseEntity {
title: string;
content: string;
authorId: number;
tags: string[];
}
export type ID = number;
export type Timestamp = string;
export type Nullable<T> = T | null;
export type AsyncResult<T> =
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
Conclusion
Turbopack combined with TypeScript provides a compelling foundation for building strongly typed applications. Turbopack's Rust-based architecture delivers exceptional performance, while TypeScript's static type system ensures correctness and maintainability. By following the patterns and best practices outlined in this tutorialβsuch as centralizing type definitions, using utility types effectively, integrating runtime validation with Zod, and separating type checking from bundlingβyou can build applications that are both fast to develop and robust in production. As Turbopack continues to mature, it will increasingly become the default choice for TypeScript projects, offering a development experience that does not force you to choose between type safety and performance. Start with strict typing from day one, leverage Turbopack's incremental caching, and your codebase will scale gracefully as your application grows.