|
| 1 | +# Code Style Guide |
| 2 | + |
| 3 | +## Quick Reference |
| 4 | + |
| 5 | +### File Placement |
| 6 | + |
| 7 | +| Code Type | Location | |
| 8 | +|-----------|----------| |
| 9 | +| React component | `components/pages/`, `components/shared/`, or `components/ui/` | |
| 10 | +| API logic | `services/{provider}/{service}/` | |
| 11 | +| Reusable function | `utils/{utilName}/` | |
| 12 | +| React hook | `hooks/use{HookName}/` | |
| 13 | +| Third-party config | `libs/{libraryName}/` | |
| 14 | + |
| 15 | +### File Extensions |
| 16 | + |
| 17 | +| Extension | Purpose | |
| 18 | +|-----------|---------| |
| 19 | +| `.ts{x}` | Main code (use `.tsx` only when file contains JSX) | |
| 20 | +| `.test.ts` | Tests | |
| 21 | +| `.types.ts` | TypeScript types | |
| 22 | +| `.utils.ts{x}` | Helper functions | |
| 23 | +| `.utils.test.ts` | Tests for utility functions | |
| 24 | +| `.schemas.ts` | Zod validation schemas | |
| 25 | +| `.schemas.test.ts` | Tests for schemas | |
| 26 | +| `.constants.ts{x}` | Static objects and constants | |
| 27 | + |
| 28 | +### Naming Conventions |
| 29 | + |
| 30 | +| Context | Convention | Example | |
| 31 | +|---------|------------|---------| |
| 32 | +| Component folders | camelCase | `userCard/` | |
| 33 | +| Component files | PascalCase | `UserCard.tsx` | |
| 34 | +| Everything else | camelCase | `httpClient.ts` | |
| 35 | +| Variables/params | Descriptive (no single chars) | `event` not `e` | |
| 36 | +| Constants | camelCase | `maxRetries` not `MAX_RETRIES` | |
| 37 | + |
| 38 | +### Exports |
| 39 | + |
| 40 | +All files use named exports: |
| 41 | + |
| 42 | +```typescript |
| 43 | +export function Component() {} |
| 44 | +``` |
| 45 | + |
| 46 | +--- |
| 47 | + |
| 48 | +## Directory Structure |
| 49 | + |
| 50 | +```text |
| 51 | +src/ |
| 52 | +├── components/ |
| 53 | +│ ├── pages/ # Page-level components |
| 54 | +│ │ └── userProfilePage/ |
| 55 | +│ │ ├── UserProfilePage.tsx |
| 56 | +│ │ └── profileView/ |
| 57 | +│ │ ├── ProfileView.tsx |
| 58 | +│ │ └── avatarSection/ |
| 59 | +│ │ └── AvatarSection.tsx |
| 60 | +│ ├── shared/ # Reusable across pages |
| 61 | +│ └── ui/ # Pure presentation components |
| 62 | +├── hooks/ |
| 63 | +│ └── useDebounce/ |
| 64 | +│ ├── useDebounce.ts |
| 65 | +│ └── useDebounce.test.ts |
| 66 | +├── libs/ # Third-party wrappers |
| 67 | +├── services/ |
| 68 | +│ └── hyperion/ |
| 69 | +│ └── users/ |
| 70 | +│ ├── users.ts |
| 71 | +│ ├── users.schemas.ts |
| 72 | +│ └── users.constants.ts |
| 73 | +├── styles/ |
| 74 | +└── utils/ |
| 75 | + └── date/ |
| 76 | + ├── date.ts # No .utils.ts extension in utils/ |
| 77 | + └── date.test.ts |
| 78 | +``` |
| 79 | + |
| 80 | +--- |
| 81 | + |
| 82 | +## Component Patterns |
| 83 | + |
| 84 | +### File Organization |
| 85 | + |
| 86 | +Components contain only JSX, Props type, and hooks. Extract everything else: |
| 87 | + |
| 88 | +```text |
| 89 | +userCard/ |
| 90 | +├── UserCard.tsx # Component + Props type only |
| 91 | +├── UserCard.types.ts # All other types |
| 92 | +├── UserCard.constants.ts # All constants |
| 93 | +└── UserCard.utils.ts # All helper functions |
| 94 | +``` |
| 95 | + |
| 96 | +### Component File |
| 97 | + |
| 98 | +```typescript |
| 99 | +// UserCard.tsx |
| 100 | +import { formatUserName } from './UserCard.utils'; |
| 101 | +import type { User } from './UserCard.types'; |
| 102 | + |
| 103 | +type Props = { // Never export Props |
| 104 | + user: User; |
| 105 | + onSelect: (id: string) => void; |
| 106 | +}; |
| 107 | + |
| 108 | +export function UserCard({ user, onSelect }: Props) { |
| 109 | + return <div onClick={() => onSelect(user.id)}>{formatUserName(user.name)}</div>; |
| 110 | +} |
| 111 | +``` |
| 112 | + |
| 113 | +### Supporting Files |
| 114 | + |
| 115 | +```typescript |
| 116 | +// UserCard.types.ts |
| 117 | +export type User = { |
| 118 | + id: string; |
| 119 | + name: string; |
| 120 | + status: 'active' | 'inactive'; |
| 121 | +}; |
| 122 | + |
| 123 | +// UserCard.constants.ts |
| 124 | +export const maxNameLength = 30; |
| 125 | + |
| 126 | +// UserCard.utils.ts |
| 127 | +import { maxNameLength } from './UserCard.constants'; |
| 128 | + |
| 129 | +export function formatUserName(name: string): string { |
| 130 | + return name.length > maxNameLength ? `${name.slice(0, maxNameLength)}...` : name; |
| 131 | +} |
| 132 | +``` |
| 133 | + |
| 134 | +--- |
| 135 | + |
| 136 | +## Service Patterns |
| 137 | + |
| 138 | +Services have three files: main functions, schemas, and constants. |
| 139 | + |
| 140 | +### Main Service File |
| 141 | + |
| 142 | +```typescript |
| 143 | +// services/hyperion/users/users.ts |
| 144 | +import { useMutation, useQuery } from '@tanstack/react-query'; |
| 145 | +import { http } from '@/utils/httpClient/httpClient'; |
| 146 | +import { api } from '@/utils/httpClient/httpClient.constants'; |
| 147 | +import { getUsersKey } from './users.constants'; |
| 148 | +import { GetUsersResponseSchema } from './users.schemas'; |
| 149 | + |
| 150 | +/* POST /api/v1/users */ |
| 151 | +export async function getUsers() { |
| 152 | + return http.get<GetUsersResponseSchema>(api.hyperion.users.v1.list, { |
| 153 | + responseSchema: GetUsersResponseSchema, |
| 154 | + }); |
| 155 | +} |
| 156 | + |
| 157 | +export function useGetUsers() { |
| 158 | + return useQuery({ |
| 159 | + queryKey: [getUsersKey], |
| 160 | + queryFn: getUsers, |
| 161 | + }); |
| 162 | +} |
| 163 | +``` |
| 164 | + |
| 165 | +### Schema File |
| 166 | + |
| 167 | +```typescript |
| 168 | +// services/hyperion/users/users.schemas.ts |
| 169 | +import { z } from 'zod'; |
| 170 | + |
| 171 | +export const GetUsersResponseSchema = z.object({ |
| 172 | + users: z.array( |
| 173 | + z.object({ |
| 174 | + id: z.uuid(), |
| 175 | + email: z.email(), |
| 176 | + name: z.string(), |
| 177 | + }), |
| 178 | + ), |
| 179 | +}); |
| 180 | +export type GetUsersResponseSchema = z.infer<typeof GetUsersResponseSchema>; |
| 181 | +``` |
| 182 | + |
| 183 | +### Constants File |
| 184 | + |
| 185 | +```typescript |
| 186 | +// services/hyperion/users/users.constants.ts |
| 187 | +export const getUsersKey = 'getUsers'; |
| 188 | +``` |
| 189 | + |
| 190 | +--- |
| 191 | + |
| 192 | +## Testing Patterns |
| 193 | + |
| 194 | +Use `test.each` with array of objects: |
| 195 | + |
| 196 | +```typescript |
| 197 | +import { describe, expect, test } from 'bun:test'; |
| 198 | +import { formatUserName } from './user'; |
| 199 | + |
| 200 | +describe('formatUserName', () => { |
| 201 | + test.each([ |
| 202 | + { |
| 203 | + description: 'full name with all parts', |
| 204 | + input: 'Smith, John David', |
| 205 | + expected: { lastName: 'Smith', firstName: 'John', middleName: 'David' }, |
| 206 | + }, |
| 207 | + { |
| 208 | + description: 'name without middle', |
| 209 | + input: 'Smith, John', |
| 210 | + expected: { lastName: 'Smith', firstName: 'John', middleName: undefined }, |
| 211 | + }, |
| 212 | + ])('should handle $description', ({ input, expected }) => { |
| 213 | + expect(formatUserName(input)).toEqual(expected); |
| 214 | + }); |
| 215 | +}); |
| 216 | +``` |
| 217 | + |
| 218 | +--- |
| 219 | + |
| 220 | +## TypeScript Rules |
| 221 | + |
| 222 | +- Use `type` for all type definitions (object shapes, unions, and advanced type operations) |
| 223 | +- Schema types: `z.infer<typeof Schema>` |
| 224 | +- **Schema names and type names should be the same**: In TypeScript, you can have a const and a type with the same name because they exist in different namespaces (value namespace vs type namespace). This keeps naming consistent and clear. |
| 225 | + |
| 226 | +### Schema Pattern |
| 227 | + |
| 228 | +```typescript |
| 229 | +// Correct: Schema and type share the same name |
| 230 | +export const UserInfoSchema = z.object({ |
| 231 | + name: z.string().default(''), |
| 232 | + email: z.string().default(''), |
| 233 | +}); |
| 234 | +export type UserInfoSchema = z.infer<typeof UserInfoSchema>; |
| 235 | + |
| 236 | +// Usage - import the const, TypeScript automatically uses the type when needed: |
| 237 | +import {UserInfoSchema} from './schemas'; |
| 238 | + |
| 239 | +// As a value (schema): |
| 240 | +const result = UserInfoSchema.parse(data); |
| 241 | +// As a type: |
| 242 | +function getUser(): UserInfoSchema { ... } |
| 243 | +``` |
| 244 | + |
| 245 | +```typescript |
| 246 | +// Wrong: Different names for schema and type |
| 247 | +export const UserInfoSchema = z.object({...}); |
| 248 | +export type UserInfo = z.infer<typeof UserInfoSchema>; // Don't rename |
| 249 | +``` |
| 250 | + |
| 251 | +```typescript |
| 252 | +// Wrong: Using type aliases when importing |
| 253 | +import {UserInfoSchema} from './schemas'; |
| 254 | +import type {UserInfoSchema as UserInfoSchemaType} from './schemas'; // Don't alias |
| 255 | +function getUser(): UserInfoSchemaType { ... } // Just use UserInfoSchema |
| 256 | +``` |
| 257 | + |
| 258 | +--- |
| 259 | + |
| 260 | +## Barrel Files |
| 261 | + |
| 262 | +**Do not use barrel files (`index.ts`/`index.js`) in application code.** |
| 263 | + |
| 264 | +Barrel files are files that only re-export from other modules. They cause problems: |
| 265 | + |
| 266 | +- **Circular imports**: Easy to accidentally create import cycles that crash bundlers |
| 267 | +- **Slow development**: Loading a barrel loads all modules it exports, even if you only need one |
| 268 | +- **Hard to optimize**: Bundlers struggle to tree-shake and optimize barrel imports |
| 269 | + |
| 270 | +### Wrong |
| 271 | + |
| 272 | +```typescript |
| 273 | +// components/ui/index.ts |
| 274 | +export { Button } from './button/Button'; |
| 275 | +export { Input } from './input/Input'; |
| 276 | +export { Card } from './card/Card'; |
| 277 | + |
| 278 | +// Usage creates circular import risk |
| 279 | +import { Button } from '@/components/ui'; |
| 280 | +``` |
| 281 | + |
| 282 | +### Right |
| 283 | + |
| 284 | +```typescript |
| 285 | +// Import directly from the module |
| 286 | +import { Button } from '@/components/ui/button/Button'; |
| 287 | +import { Input } from '@/components/ui/input/Input'; |
| 288 | +``` |
| 289 | + |
| 290 | +### Exception: NPM Libraries |
| 291 | + |
| 292 | +Barrel files are **only** acceptable as the single entry point for npm packages: |
| 293 | + |
| 294 | +```typescript |
| 295 | +// packages/my-library/index.ts (package.json "main" field) |
| 296 | +export { Button } from './components/Button'; |
| 297 | +export { useTheme } from './hooks/useTheme'; |
| 298 | +``` |
| 299 | + |
| 300 | +**Reference**: [Please Stop Using Barrel Files](https://tkdodo.eu/blog/please-stop-using-barrel-files#what-barrels-are-good-for) |
| 301 | + |
| 302 | +--- |
| 303 | + |
| 304 | +## Component Hierarchy |
| 305 | + |
| 306 | +Subcomponents belong to the component that imports them: |
| 307 | + |
| 308 | +```text |
| 309 | +announcementsPage/ |
| 310 | +├── AnnouncementsPage.tsx # List page |
| 311 | +└── announcementPage/ # Detail page (sibling) |
| 312 | + ├── AnnouncementPage.tsx |
| 313 | + └── announcementForm/ # Child of AnnouncementPage |
| 314 | + └── AnnouncementForm.tsx |
| 315 | +``` |
| 316 | + |
| 317 | +--- |
| 318 | + |
| 319 | +## Common Mistakes |
| 320 | + |
| 321 | +| Wrong | Right | |
| 322 | +|-------|-------| |
| 323 | +| `interface Props {...}` | `type Props = {...}` | |
| 324 | +| `export type Props` | `type Props` (no export) | |
| 325 | +| Types in component file | Move to `.types.ts` (except Props) | |
| 326 | +| `utils/dateUtils/dateUtils.ts` | `utils/date/date.ts` | |
| 327 | +| `const MAX_RETRIES = 3` | `const maxRetries = 3` | |
| 328 | +| `(e) => handleClick(e)` | `(event) => handleClick(event)` | |
| 329 | +| Constants in component | Extract to `.constants.ts` | |
| 330 | +| Helpers in component | Extract to `.utils.ts` | |
| 331 | +| Multiple components per file | One component per file | |
| 332 | +| Barrel files (`index.ts`) | Direct imports from modules | |
| 333 | +| Default exports | Named exports | |
| 334 | +| Re-exporting types/values | Import directly from source file | |
0 commit comments