← Back to DevBytes

Turbopack TypeScript: Strongly Typed Applications

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:

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:

Setting Up a Turbopack TypeScript Project

Prerequisites

Before you begin, ensure you have the following installed:

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.

β€” Ad β€”

Google AdSense will appear here after approval

← Back to all articles