Advanced TypeScript Patterns with Next.js 14
Master type-safe development with advanced TypeScript patterns, generic constraints, and Next.js 14 server components for bulletproof applications.
TypeScript and Next.js 14 together create a powerful combination for building type-safe, performant web applications. In this comprehensive guide, we'll explore advanced patterns that will take your development to the next level.
Why Type Safety Matters
Type safety isn't just about catching errors at compile time—it's about building confidence in your codebase. When you can trust your types, you can refactor fearlessly, onboard teammates faster, and ship with confidence.
// ❌ Unsafe: Runtime errors waiting to happen
function getUser(id) {
return fetch(`/api/users/${id}`).then(r => r.json())
}
// ✅ Type-safe: Errors caught at compile time
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`)
const data = await response.json()
return userSchema.parse(data) // Runtime validation
}
Advanced Generic Constraints
Generics are powerful, but constrained generics are game-changing. They let you write reusable code while maintaining strict type safety.
Pattern 1: Constrained Component Props
interface BaseEntity {
id: string
createdAt: Date
}
interface DataTableProps<T extends BaseEntity> {
data: T[]
columns: Column<T>[]
onRowClick?: (row: T) => void
}
// Type-safe for any entity with id and createdAt
function DataTable<T extends BaseEntity>({
data,
columns,
onRowClick
}: DataTableProps<T>) {
return (
<table>
{data.map(row => (
<tr key={row.id} onClick={() => onRowClick?.(row)}>
{columns.map(col => (
<td key={col.key}>{col.render(row)}</td>
))}
</tr>
))}
</table>
)
}
Pattern 2: Discriminated Unions
Discriminated unions are perfect for modeling state machines and API responses:
type ApiResponse<T> =
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'success'; data: T }
function useApiData<T>(url: string): ApiResponse<T> {
const [response, setResponse] = useState<ApiResponse<T>>({
status: 'loading'
})
useEffect(() => {
fetch(url)
.then(r => r.json())
.then(data => setResponse({ status: 'success', data }))
.catch(error => setResponse({ status: 'error', error: error.message }))
}, [url])
return response
}
// Usage with exhaustive type checking
function UserProfile({ userId }: { userId: string }) {
const response = useApiData<User>(`/api/users/${userId}`)
switch (response.status) {
case 'loading':
return <Spinner />
case 'error':
return <Error message={response.error} />
case 'success':
return <Profile user={response.data} />
// TypeScript ensures all cases are handled!
}
}
Type-Safe Server Actions in Next.js 14
Server Actions are one of the most powerful features of Next.js 14, but they need careful typing:
'use server'
import { z } from 'zod'
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string()),
})
type CreatePostInput = z.infer<typeof createPostSchema>
type CreatePostResult =
| { success: true; post: Post }
| { success: false; error: string }
export async function createPost(
input: CreatePostInput
): Promise<CreatePostResult> {
// Validate input
const result = createPostSchema.safeParse(input)
if (!result.success) {
return {
success: false,
error: result.error.issues[0].message
}
}
// Type-safe database operation
const post = await db.post.create({
data: result.data,
})
return { success: true, post }
}
Contentlayer Type Generation
When working with MDX content in Next.js, Contentlayer provides automatic type generation:
import { allPosts } from 'contentlayer/generated'
// ✅ Fully typed!
const posts = allPosts.filter(post => post.published)
const featuredPost = posts.find(post => post.featured)
// ✅ TypeScript knows all your frontmatter fields
if (featuredPost) {
console.log(featuredPost.title) // string
console.log(featuredPost.publishedAt) // Date
console.log(featuredPost.tags) // string[]
console.log(featuredPost.readingTime) // number (computed field)
}
Mapped Types for Dynamic Forms
Build type-safe forms that adapt to your data schema:
type FormValues<T> = {
[K in keyof T]: T[K] extends Date
? string
: T[K] extends number
? string
: T[K]
}
type FormErrors<T> = {
[K in keyof T]?: string
}
interface User {
name: string
age: number
email: string
birthDate: Date
}
// Automatically converts types for form inputs
type UserFormValues = FormValues<User>
// Result: { name: string; age: string; email: string; birthDate: string }
function useForm<T extends Record<string, any>>(
initialValues: T
): {
values: FormValues<T>
errors: FormErrors<T>
handleChange: (field: keyof T, value: any) => void
validate: () => boolean
} {
// Implementation...
}
Type-Safe Environment Variables
Never access process.env directly again:
import { z } from 'zod'
const envSchema = z.object({
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_FIREBASE_API_KEY: z.string(),
DATABASE_URL: z.string(),
STRIPE_SECRET_KEY: z.string(),
})
export const env = envSchema.parse({
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_FIREBASE_API_KEY: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
DATABASE_URL: process.env.DATABASE_URL,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
})
// ✅ Typed and validated!
const apiUrl = env.NEXT_PUBLIC_API_URL
Best Practices
1. Use satisfies for Type Narrowing
const routes = {
home: '/',
about: '/about',
blog: '/blog',
} satisfies Record<string, string>
// ✅ Autocomplete works + type-safe
type Route = keyof typeof routes // 'home' | 'about' | 'blog'
2. Leverage Template Literal Types
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type Route = '/api/users' | '/api/posts' | '/api/comments'
type Endpoint = `${HTTPMethod} ${Route}`
// Result: 'GET /api/users' | 'POST /api/users' | 'PUT /api/users' ...
3. Use Type Predicates for Runtime Checks
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value
)
}
// ✅ TypeScript knows it's a User after the check
if (isUser(data)) {
console.log(data.email) // No type error!
}
Conclusion
Mastering these TypeScript patterns will significantly improve your Next.js development experience. You'll catch more bugs at compile time, write more maintainable code, and build confidence in your applications.
The key is to think in types—not as a constraint, but as a powerful tool for expressing your application's business logic and state management clearly and safely.
Next Steps
- Explore Zod for runtime validation
- Learn about branded types for domain modeling
- Study how to integrate TypeScript with your database ORM
- Practice writing type-safe API clients
Happy coding! 🚀