| name | typescript-patterns |
| description | Use when writing TypeScript in this project — API DTOs, component prop types, hook generics, strict null handling, and avoiding common TS mistakes with the existing codebase patterns. |
TypeScript Patterns
Golden rule — import types, never re-declare
All API DTOs are in src/types/api.ts. Import them everywhere:
import type { Remnant, WorkOrder, PaginatedResponse } from '@/types/api'
interface Remnant { id: string; ... }
If the type doesn't exist yet in src/types/api.ts, add it there — not locally.
Component prop types — extend HTML elements
Follow the ComponentProps<> pattern used in all shadcn components:
import * as React from 'react'
interface ButtonProps extends React.ComponentProps<'button'> {
variant?: 'primary' | 'danger'
isLoading?: boolean
}
import type { buttonVariants } from '@/components/ui/button'
import type { VariantProps } from 'class-variance-authority'
interface MyButtonProps
extends React.ComponentProps<'button'>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
interface BadButtonProps {
onClick: () => void
disabled: boolean
className: string
}
TanStack Query generics
Always specify the generic types for full type inference and better error handling:
import { useQuery, useMutation } from '@tanstack/react-query'
import type { Remnant, PaginatedResponse } from '@/types/api'
import type { ApiClientError } from '@/lib/api/client'
const { data } = useQuery<PaginatedResponse<Remnant>, ApiClientError>({
queryKey: ['remnants'],
queryFn: () => remnantsApi.list(),
})
const mutation = useMutation<Remnant, ApiClientError, { id: string; locationId: string }>({
mutationFn: ({ id, locationId }) => remnantsApi.assignLocation(id, locationId),
})
Strict null / undefined handling
The project uses "strict": true. Always handle nullable values:
const { data } = useRemnants()
const count = data?.total ?? 0
const items = data?.data ?? []
if (!data) return <Skeleton />
const id = remnant!.id
const id = data!.total
ApiClientError — typed error handling
import { ApiClientError } from '@/lib/api/client'
useMutation({
mutationFn: myApi.create,
onError: (err: ApiClientError) => {
if (err.status === 422) {
const fieldErrors = err.details ?? {}
setErrors(fieldErrors)
} else {
toast.error(err.message)
}
},
})
try {
await myApi.create(input)
} catch (err) {
if (err instanceof ApiClientError) {
if (err.status === 404) return toast.error('Không tìm thấy')
if (err.status === 409) return toast.error('Đã tồn tại')
}
toast.error('Lỗi không xác định')
}
Avoiding any
const data: any = await fetch(...)
const raw: unknown = JSON.parse(qrCode)
if (raw && typeof raw === 'object' && 'id' in raw) {
const id = (raw as { id: string }).id
}
function isQrPayload(v: unknown): v is QrPayload {
return typeof v === 'object' && v !== null && 'type' in v && 'id' in v
}
const parsed: unknown = JSON.parse(code)
if (isQrPayload(parsed)) {
handleScan(parsed)
}
Zustand store typing
Follow the pattern in src/lib/hooks/use-scan.ts:
import { create } from 'zustand'
interface MyStore {
count: number
items: string[]
increment: () => void
addItem: (item: string) => void
reset: () => void
}
export const useMyStore = create<MyStore>((set) => ({
count: 0,
items: [],
increment: () => set((s) => ({ count: s.count + 1 })),
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
reset: () => set({ count: 0, items: [] }),
}))
cn() utility for conditional classes
import { cn } from '@/lib/utils'
className={cn('base-class', conditionalClass && 'applied-if-truthy')}
className={cn(
'flex items-center',
isActive && 'bg-primary text-primary-foreground',
isDisabled && 'opacity-50 pointer-events-none',
className,
)}
cn('px-4 px-6')
cn('text-sm', 'text-lg')
Common import paths
import type { Remnant, WorkOrder, BarcodeRecord } from '@/types/api'
import { apiClient, ApiClientError } from '@/lib/api/client'
import { remnantsApi } from '@/lib/api/remnants'
import { cuttingOrdersApi } from '@/lib/api/cutting-orders'
import { useRemnants, useAllocateRemnant } from '@/lib/hooks/use-remnants'
import { useRecordCut } from '@/lib/hooks/use-cutting-orders'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { BigButton } from '@/components/kiosk/big-button'
import { ScannerView } from '@/components/kiosk/scanner-view'
import { LabelPreview } from '@/components/kiosk/label-preview'
import { StatCard } from '@/components/dashboard/stat-card'
import { AlertBanner } from '@/components/dashboard/alert-banner'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'