TypeScript Best Practices
This skill provides strict TypeScript conventions for the fitness application.
Core Principles
Strict TypeScript: All code must be strictly typed. The project uses strict mode with no exceptions.
Prefer Interfaces: Use interfaces over type aliases for better extendability and declaration merging.
No Enums: Avoid TypeScript enums. Use const objects (maps) for better type safety and flexibility.
Type Safety Rules
✅ MUST DO
1. Explicit typing for all code:
// ✅ Correct: Explicitly typed
function calculateBMI(weight: number, height: number): number {
return weight / (height * height)
}
// ✅ Correct: Typed parameters
const users: User[] = []
const count: number = users.length
2. Type component props and emits:
<script setup lang="ts">
interface Props {
workout: Workout
isActive?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
update: [workout: Workout]
delete: [id: string]
}>()
</script>
3. Use unknown instead of any:
// ✅ Correct: Use unknown for type-safe handling
function processData(data: unknown) {
if (typeof data === 'string') {
return data.toUpperCase()
}
throw new Error('Invalid data type')
}
// ❌ Wrong: Using any
function processData(data: any) {
return data.toUpperCase() // Unsafe
}
4. Implement type guards:
interface Workout {
id: string
name: string
}
function isWorkout(value: unknown): value is Workout {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
typeof (value as Workout).id === 'string' &&
typeof (value as Workout).name === 'string'
)
}
// Usage
function processData(data: unknown) {
if (isWorkout(data)) {
// TypeScript knows data is Workout here
console.log(data.name)
}
}
❌ NEVER DO
1. Never use any:
// ❌ Wrong
function process(data: any) {
return data.property // Unsafe
}
// ✅ Correct
function process(data: unknown) {
if (isValidData(data)) {
return data.property // Type-safe
}
}
2. Never use @ts-ignore:
// ❌ Wrong: Ignoring TypeScript errors // @ts-ignore const value = dangerousOperation() // ✅ Correct: Fix the underlying issue or use proper type assertion const value = dangerousOperation() as ExpectedType
3. Never use TypeScript enums:
// ❌ Wrong: TypeScript enum
enum WorkoutType {
Strength = 'strength',
Cardio = 'cardio',
}
// ✅ Correct: Const object
const WORKOUT_TYPE = {
STRENGTH: 'strength',
CARDIO: 'cardio',
} as const
type WorkoutType = typeof WORKOUT_TYPE[keyof typeof WORKOUT_TYPE]
Interfaces vs Types
Prefer Interfaces
Interfaces are preferred for object shapes because they:
- •Support declaration merging
- •Provide better error messages
- •Are more extensible
// ✅ Correct: Interface for object shapes
interface User {
id: string
name: string
email: string
}
interface AdminUser extends User {
permissions: string[]
}
// Declaration merging works with interfaces
interface User {
createdAt: Date
}
When to Use Types
Use type aliases for:
- •Unions
- •Intersections
- •Primitives
- •Mapped types
- •Conditional types
// ✅ Correct: Type for unions
type Status = 'active' | 'inactive' | 'pending'
// ✅ Correct: Type for intersections
type TimestampedUser = User & {
createdAt: Date
updatedAt: Date
}
// ✅ Correct: Mapped type
type ReadonlyUser = {
readonly [K in keyof User]: User[K]
}
Database-First Types
Derive all types from Drizzle DB schemas:
// ✅ Correct: Derive from DB schema
import { type InferSelectModel, type InferInsertModel } from 'drizzle-orm'
import { workouts } from '~~/server/database/schema/workouts'
type Workout = InferSelectModel<typeof workouts>
type InsertWorkout = InferInsertModel<typeof workouts>
// ✅ Correct: Extend DB types for specific use cases
interface WorkoutWithExercises extends Workout {
exercises: Exercise[]
}
// ❌ Wrong: Manually defining types that mirror DB schemas
interface Workout {
id: string
name: string
createdAt: Date
// ... duplicating schema
}
Error Types
Use proper, specific error types:
// ✅ Correct: Specific error types
class NotFoundError extends Error {
statusCode = 404
constructor(message: string) {
super(message)
this.name = 'NotFoundError'
}
}
class ValidationError extends Error {
statusCode = 400
constructor(
message: string,
public field: string
) {
super(message)
this.name = 'ValidationError'
}
}
// Usage
function getWorkout(id: string): Workout {
const workout = findWorkout(id)
if (!workout) {
throw new NotFoundError(`Workout ${id} not found`)
}
return workout
}
Const Assertions
Use as const for immutable values:
// ✅ Correct: Const assertion for literal types
const WORKOUT_TYPES = {
STRENGTH: 'strength',
CARDIO: 'cardio',
FLEXIBILITY: 'flexibility',
} as const
type WorkoutType = typeof WORKOUT_TYPES[keyof typeof WORKOUT_TYPES]
// Type: 'strength' | 'cardio' | 'flexibility'
// ✅ Correct: Const assertion for arrays
const DIFFICULTY_LEVELS = ['beginner', 'intermediate', 'advanced'] as const
type DifficultyLevel = typeof DIFFICULTY_LEVELS[number]
// Type: 'beginner' | 'intermediate' | 'advanced'
Generic Types
Use generics for reusable, type-safe functions:
// ✅ Correct: Generic type for API responses
interface ApiResponse<T> {
data: T
meta: {
page: number
total: number
}
}
function fetchList<T>(endpoint: string): Promise<ApiResponse<T>> {
return $fetch<ApiResponse<T>>(endpoint)
}
// Usage with type inference
const workouts = await fetchList<Workout>('/api/workouts')
// workouts.data is Workout[]
Utility Types
Leverage TypeScript utility types:
// Partial - make all properties optional
type UpdateWorkout = Partial<Workout>
// Pick - select specific properties
type WorkoutSummary = Pick<Workout, 'id' | 'name' | 'createdAt'>
// Omit - exclude specific properties
type WorkoutWithoutTimestamps = Omit<Workout, 'createdAt' | 'updatedAt'>
// Required - make all properties required
type RequiredUser = Required<User>
// Record - create object type with specific keys
type WorkoutMap = Record<string, Workout>
// ReturnType - extract return type of function
function getWorkout() {
return { id: '1', name: 'Test' }
}
type WorkoutReturn = ReturnType<typeof getWorkout>
Discriminated Unions
Use discriminated unions for type-safe state handling:
// ✅ Correct: Discriminated union
type LoadingState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
function renderWorkout(state: LoadingState<Workout>) {
switch (state.status) {
case 'idle':
return 'Not loaded'
case 'loading':
return 'Loading...'
case 'success':
return state.data.name // TypeScript knows data exists
case 'error':
return state.error.message // TypeScript knows error exists
}
}
Zod Integration
Use Zod for runtime validation with TypeScript inference:
import { z } from 'zod'
// ✅ Correct: Define Zod schema
const createWorkoutSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
exercises: z.array(z.uuid()).min(1),
})
// Infer TypeScript type from Zod schema
type CreateWorkout = z.infer<typeof createWorkoutSchema>
// Use in validation
function validateWorkout(data: unknown): CreateWorkout {
return createWorkoutSchema.parse(data)
}
Function Types
Type function parameters and return values:
// ✅ Correct: Explicitly typed function
function calculateVolume(
sets: number,
reps: number,
weight: number
): number {
return sets * reps * weight
}
// ✅ Correct: Typed arrow function
const calculateCalories = (
duration: number,
intensity: number
): number => {
return duration * intensity * 3.5
}
// ✅ Correct: Function type
type MathOperation = (a: number, b: number) => number
const add: MathOperation = (a, b) => a + b
const subtract: MathOperation = (a, b) => a - b
Async Function Types
Always type async functions:
// ✅ Correct: Typed async function
async function fetchWorkout(id: string): Promise<Workout> {
const workout = await $fetch<Workout>(`/api/workouts/${id}`)
return workout
}
// ✅ Correct: Async function with error handling
async function createWorkout(
data: InsertWorkout
): Promise<Workout | null> {
try {
return await $fetch<Workout>('/api/workouts', {
method: 'POST',
body: data
})
} catch {
return null
}
}
Branded Types
Use branded types for type-safe IDs:
// ✅ Correct: Branded type for IDs
type WorkoutId = string & { readonly __brand: 'WorkoutId' }
type UserId = string & { readonly __brand: 'UserId' }
function createWorkoutId(id: string): WorkoutId {
return id as WorkoutId
}
function getWorkout(id: WorkoutId): Workout {
// ...
}
// Type safety prevents mixing IDs
const workoutId = createWorkoutId('workout-123')
const userId = createUserId('user-456')
getWorkout(workoutId) // ✅ OK
getWorkout(userId) // ❌ Type error
Type Narrowing
Use type narrowing for safe type handling:
function processValue(value: string | number | null) {
// Type narrowing with typeof
if (typeof value === 'string') {
return value.toUpperCase() // value is string
}
if (typeof value === 'number') {
return value.toFixed(2) // value is number
}
// value is null here
return 'No value'
}
// Type narrowing with in operator
interface Workout {
name: string
}
interface Exercise {
title: string
}
function getName(item: Workout | Exercise): string {
if ('name' in item) {
return item.name // item is Workout
}
return item.title // item is Exercise
}
Summary: TypeScript Rules
Type Safety
- •✅ Use strict TypeScript everywhere
- •✅ Prefer interfaces for object shapes
- •✅ Use type aliases for unions and intersections
- •✅ Implement type guards for runtime validation
- •✅ Use
unknowninstead ofany - •❌ Never use
any - •❌ Never use
@ts-ignore - •❌ Never use TypeScript enums
Database Types
- •✅ Derive types from Drizzle schemas
- •✅ Use
InferSelectModelandInferInsertModel - •❌ Don't manually define types that mirror DB schemas
Best Practices
- •✅ Type all function parameters and returns
- •✅ Type component props and emits
- •✅ Use const assertions for literal types
- •✅ Leverage utility types (Partial, Pick, Omit, etc.)
- •✅ Use discriminated unions for state
- •✅ Integrate Zod for runtime validation
Reference Files
For advanced patterns:
- •
references/advanced-types.md- Advanced TypeScript patterns - •
references/zod-integration.md- Zod and TypeScript integration