|
| 1 | +# TypeScript Guidelines |
| 2 | + |
| 3 | +## Start from the beginning |
| 4 | + |
| 5 | +The best way to approach typing in an application is always from the foundations, defining the types of your data at the first moment it appears in your code. |
| 6 | + |
| 7 | +- In a backend application that queries a database, start by typing your database models. |
| 8 | +- In a frontend application that queries an API, start by typing the API responses. |
| 9 | + |
| 10 | +You can save a lot of work by adding libraries to your stack that generate types automatically (TypeScript ORMs, API clients, Swagger/OpenAPI codegen, GraphQL codegen...). |
| 11 | + |
| 12 | +## Don't repeat yourself |
| 13 | + |
| 14 | +Never repeat types, use and abuse [generics](https://www.typescriptlang.org/docs/handbook/2/generics.html) and [utility types](https://www.typescriptlang.org/docs/handbook/utility-types.html) to derive them. |
| 15 | + |
| 16 | +Some examples: |
| 17 | + |
| 18 | +```ts |
| 19 | +enum UserRole { |
| 20 | + ADMIN = "admin", |
| 21 | + USER = "user", |
| 22 | +} |
| 23 | + |
| 24 | +type User = { |
| 25 | + email: string; |
| 26 | + name: string; |
| 27 | + phone: number; |
| 28 | + role: UserRole; |
| 29 | + productIds?: number[]; |
| 30 | +}; |
| 31 | + |
| 32 | +type CreateUserFormData = Partial<Pick<User, "email" | "name" | "phone">>; |
| 33 | + |
| 34 | +type CreateUserPayload = Required<UserContactFormData>; |
| 35 | + |
| 36 | +type APIRequest<T> = Promise<{ data: T; statusCode: number }>; |
| 37 | + |
| 38 | +type UserRequest = APIRequest<User>; |
| 39 | + |
| 40 | +type PopulatedUser = Omit<User, "productIds"> & { products: Product[] }; |
| 41 | + |
| 42 | +type RequestResponse = Awaited<UserRequest>["data"]; // -> User |
| 43 | +``` |
| 44 | + |
| 45 | +### Export every type |
| 46 | + |
| 47 | +In an ideal world, all libraries would export the types of the objects they provide access to. Unfortunately, this is not always the case, so export every type you create. |
| 48 | +If you find yourself working with types that you don't have direct access to, create your derivatives as soon as possible. |
| 49 | +Unwrapping an inaccessible type can be too complex and is often a verbose and difficult-to-read operation. If all types were exported, we could avoid things like: |
| 50 | + |
| 51 | +```ts |
| 52 | +// Imagine an external library that doesn't export its types |
| 53 | +// We have to 'unwrap' the types we need |
| 54 | +type OperationResult<T> = Awaited<ReturnType<typeof calculationLib["calculateNow"] extends (arg: infer A) => infer R ? (arg: T) => R : never>>; |
| 55 | + |
| 56 | +type OperationData = { a: number; b: number }; |
| 57 | +const data: OperationData = { a: 2, b: 3 }; |
| 58 | + |
| 59 | +// Complex usage with extensive type annotations |
| 60 | +const result: OperationResult<OperationData> = await calculationLib.calculateNow( |
| 61 | + data, |
| 62 | + { someOptions: { operation: 'a + b', someFlag: true } } |
| 63 | +); |
| 64 | + |
| 65 | +// Trying to extract option types |
| 66 | +type SumOptions = Omit<Parameters<(typeof calculationLib)["calculateNow"]<OperationData>>[1]["someOptions"], 'operation'>; |
| 67 | + |
| 68 | +// Helper function to simplify usage, but with complex types |
| 69 | +const sum = (a: number, b: number, options: SumOptions): Promise<OperationResult<OperationData>> => |
| 70 | + calculationLib.calculateNow( |
| 71 | + { a, b }, |
| 72 | + { someOptions: { operation: 'a + b', ...options } } |
| 73 | + ); |
| 74 | + |
| 75 | +const sumResult = sum(3, 5, { someFlag: false }); |
| 76 | +``` |
| 77 | + |
| 78 | +Instead of the above, with exported types it would be simpler: |
| 79 | + |
| 80 | +```ts |
| 81 | +import { Operation, OperationOptions, OperationResult } from "calculation-lib"; |
| 82 | + |
| 83 | +const sum = ( |
| 84 | + a: number, |
| 85 | + b: number, |
| 86 | + options: Omit<OperationOptions, "operation">, |
| 87 | +): Promise<OperationResult<number>> => |
| 88 | + calculationLib.calculateNow({ a, b }, { operation: "a + b", ...options }); |
| 89 | +``` |
| 90 | + |
| 91 | +### Avoid casting when possible |
| 92 | + |
| 93 | +Excessive use of casting (`as Type`, `<Type>value`) often indicates problems in the design of types or the structure of the code. If you find yourself using many casts, you may be fighting against the type system rather than leveraging it. |
| 94 | + |
| 95 | +Casts create blind spots in the type system, as you're telling the compiler to "trust you" rather than properly verifying types. This can lead to runtime errors that are difficult to detect. |
| 96 | + |
| 97 | +```ts |
| 98 | +// ❌ Bad: Excessive use of casting |
| 99 | +function processData(data: any) { |
| 100 | + const user = data as User; |
| 101 | + const products = (data.items as any[]).map((item) => item as Product); |
| 102 | + return { |
| 103 | + user, |
| 104 | + products, |
| 105 | + total: data.total as string as unknown as number, |
| 106 | + }; |
| 107 | +} |
| 108 | +``` |
| 109 | + |
| 110 | +#### Alternatives to casting |
| 111 | + |
| 112 | +1. **Type Guards**: Functions that help TypeScript recognize types at runtime. |
| 113 | + |
| 114 | +```ts |
| 115 | +function isString(value: unknown): value is string { |
| 116 | + return typeof value === "string"; |
| 117 | +} |
| 118 | + |
| 119 | +function processValue(value: unknown) { |
| 120 | + if (isString(value)) { |
| 121 | + // Here TypeScript knows that value is a string |
| 122 | + return value.toUpperCase(); |
| 123 | + } |
| 124 | + return String(value); |
| 125 | +} |
| 126 | +``` |
| 127 | + |
| 128 | +2. **Assertion Functions**: Functions that throw an error if the condition is not met. |
| 129 | + |
| 130 | +```ts |
| 131 | +function assertIsString(value: unknown): asserts value is string { |
| 132 | + if (typeof value !== "string") { |
| 133 | + throw new Error("Value must be a string"); |
| 134 | + } |
| 135 | +} |
| 136 | + |
| 137 | +function processValue(value: unknown) { |
| 138 | + assertIsString(value); |
| 139 | + // Here TypeScript knows that value is a string |
| 140 | + return value.toUpperCase(); |
| 141 | +} |
| 142 | +``` |
| 143 | + |
| 144 | +3. **Validation Schemas**: Use libraries like Zod, io-ts, or Ajv to validate and type data simultaneously. |
| 145 | + |
| 146 | +```ts |
| 147 | +import { z } from "zod"; |
| 148 | + |
| 149 | +const UserSchema = z.object({ |
| 150 | + email: z.string().email(), |
| 151 | + name: z.string(), |
| 152 | + age: z.number().int().positive(), |
| 153 | +}); |
| 154 | + |
| 155 | +type User = z.infer<typeof UserSchema>; |
| 156 | + |
| 157 | +function processUser(data: unknown) { |
| 158 | + // Validates and converts data to User |
| 159 | + const user = UserSchema.parse(data); |
| 160 | + |
| 161 | + // user is fully typed as User |
| 162 | + return `${user.name} (${user.email})`; |
| 163 | +} |
| 164 | +``` |
| 165 | + |
| 166 | +The goal should always be to create code that is type-safe by design, rather than forcing types with casting. |
| 167 | + |
| 168 | +### Don't carry nullable values in function parameters |
| 169 | + |
| 170 | +An important principle in type design is to avoid "carrying" nullable values (`null` or `undefined`) through the function chain. If a parameter can be nullable, it's better to handle it as early as possible in your code. |
| 171 | + |
| 172 | +When you allow nullable values to propagate through multiple functions, each function needs to check if the value is nullable, which causes: |
| 173 | + |
| 174 | +1. Code repetition (each function repeats the same checks) |
| 175 | +2. Increased complexity (code full of conditional checks) |
| 176 | +3. Higher probability of errors (if a check is forgotten) |
| 177 | +4. Less readable and harder to maintain code |
| 178 | + |
| 179 | +```ts |
| 180 | +// ❌ Bad: Propagating nullable values through multiple functions |
| 181 | +function getUserByEmail(email: string | null): User | null { |
| 182 | + if (!email) return null; |
| 183 | + return findUser(email); |
| 184 | +} |
| 185 | + |
| 186 | +function getUserName(email: string | null): string { |
| 187 | + const user = getUserByEmail(email); |
| 188 | + // Now we have to check again if user is null |
| 189 | + return user ? user.name : "Unknown User"; |
| 190 | +} |
| 191 | + |
| 192 | +function greetUser(email: string | null): string { |
| 193 | + const name = getUserName(email); |
| 194 | + return `Hello, ${name}!`; |
| 195 | +} |
| 196 | + |
| 197 | +// This works, but each function must handle the null case |
| 198 | +const greeting = greetUser(email); |
| 199 | +``` |
| 200 | + |
| 201 | +Instead, it's better to validate nullable values as early as possible and work only with non-nullable values: |
| 202 | + |
| 203 | +```ts |
| 204 | +// ✅ Good: Early handling of nullable values |
| 205 | +function getUserByEmail(email: string): User | null { |
| 206 | + return findUser(email); |
| 207 | +} |
| 208 | + |
| 209 | +function getUserName(user: User): string { |
| 210 | + return user.name; |
| 211 | +} |
| 212 | + |
| 213 | +function greetUser(name: string): string { |
| 214 | + return `Hello, ${name}!`; |
| 215 | +} |
| 216 | + |
| 217 | +// Handling the nullable case in one place |
| 218 | +function processGreeting(email: string | null): string { |
| 219 | + if (!email) return "Hello, visitor!"; |
| 220 | + |
| 221 | + const user = getUserByEmail(email); |
| 222 | + if (!user) return "Hello, user not found!"; |
| 223 | + |
| 224 | + const name = getUserName(user); |
| 225 | + return greetUser(name); |
| 226 | +} |
| 227 | + |
| 228 | +const greeting = processGreeting(email); |
| 229 | +``` |
| 230 | + |
| 231 | +#### Benefits of early nullable handling |
| 232 | + |
| 233 | +1. **Simpler functions**: Each function has a clear purpose and doesn't worry about nullable values. |
| 234 | +2. **Better type inference**: TypeScript can infer more precise types, reducing the need for type annotations. |
| 235 | +3. **Safer code**: Less likelihood of `TypeError: Cannot read property 'x' of null` errors. |
| 236 | +4. **Better testability**: Functions with non-nullable inputs are easier to test. |
| 237 | + |
| 238 | +#### Techniques for handling nullable values |
| 239 | + |
| 240 | +1. **Early validation**: |
| 241 | + |
| 242 | +```ts |
| 243 | +function processData(data: string | null | undefined): void { |
| 244 | + if (data == null) { |
| 245 | + // Handle the nullable case |
| 246 | + return; |
| 247 | + } |
| 248 | + |
| 249 | + // From here on data is a string |
| 250 | + const upperCaseData = data.toUpperCase(); |
| 251 | + // ... |
| 252 | +} |
| 253 | +``` |
| 254 | + |
| 255 | +2. **Default values**: |
| 256 | + |
| 257 | +```ts |
| 258 | +function processConfig(config: Config = defaultConfig): void { |
| 259 | + // We always work with a non-nullable value |
| 260 | + const timeout = config.timeout ?? 5000; |
| 261 | + // ... |
| 262 | +} |
| 263 | +``` |
| 264 | + |
| 265 | +3. **Pattern matching / discriminated unions**: |
| 266 | + |
| 267 | +```ts |
| 268 | +type Result<T> = { ok: true; value: T } | { ok: false; error: string }; |
| 269 | + |
| 270 | +function processResult<T>(result: Result<T>): void { |
| 271 | + if (!result.ok) { |
| 272 | + // Error handling |
| 273 | + console.error(result.error); |
| 274 | + return; |
| 275 | + } |
| 276 | + |
| 277 | + // TypeScript knows that result.value is present |
| 278 | + const value = result.value; |
| 279 | + // ... |
| 280 | +} |
| 281 | +``` |
| 282 | + |
| 283 | +This approach of handling nullable values early in the application flow and working with non-nullable types in most of the code leads to a more robust and easier to maintain system. |
| 284 | + |
| 285 | +#### Nullables in React functional components |
| 286 | + |
| 287 | +This principle is **especially important** in React functional components. React components often receive props that may be nullable, and it's easy to fall into patterns where these checks are repeated in multiple components or in multiple parts of the same component. |
| 288 | + |
| 289 | +```tsx |
| 290 | +// ❌ Bad: Propagating nullable props in nested components |
| 291 | +type UserCardProps = { |
| 292 | + user: User | null; |
| 293 | +}; |
| 294 | + |
| 295 | +const UserCard: React.FC<UserCardProps> = ({ user }) => { |
| 296 | + // Repetitive check |
| 297 | + if (!user) return <div>No user</div>; |
| 298 | + |
| 299 | + return ( |
| 300 | + <div className="user-card"> |
| 301 | + <UserHeader user={user} /> |
| 302 | + <UserDetails user={user} /> |
| 303 | + <UserActions user={user} /> |
| 304 | + </div> |
| 305 | + ); |
| 306 | +}; |
| 307 | + |
| 308 | +const UserHeader: React.FC<UserCardProps> = ({ user }) => { |
| 309 | + // We have to check again |
| 310 | + if (!user) return null; |
| 311 | + |
| 312 | + return <h2>{user.name}</h2>; |
| 313 | +}; |
| 314 | + |
| 315 | +const UserDetails: React.FC<UserCardProps> = ({ user }) => { |
| 316 | + // And again |
| 317 | + if (!user) return null; |
| 318 | + |
| 319 | + return ( |
| 320 | + <div className="details"> |
| 321 | + <p>Email: {user.email}</p> |
| 322 | + {/* ... */} |
| 323 | + </div> |
| 324 | + ); |
| 325 | +}; |
| 326 | +``` |
| 327 | + |
| 328 | +Better approach: |
| 329 | + |
| 330 | +1. **Validation in the main component**: |
| 331 | + |
| 332 | +```tsx |
| 333 | +const UserProfile: React.FC<{ userId: string | null }> = ({ userId }) => { |
| 334 | + // Handle the nullable once |
| 335 | + if (!userId) return <div>Please select a user</div>; |
| 336 | + |
| 337 | + return <UserProfileContent userId={userId} />; |
| 338 | +}; |
| 339 | + |
| 340 | +// This component always receives a non-nullable userId |
| 341 | +const UserProfileContent: React.FC<{ userId: string }> = ({ userId }) => { |
| 342 | + // We don't need to check if userId is null |
| 343 | + const { data: user, loading, error } = useUser(userId); |
| 344 | + |
| 345 | + if (loading) return <Spinner />; |
| 346 | + if (error) return <ErrorMessage error={error} />; |
| 347 | + if (!user) return <NotFoundMessage />; |
| 348 | + |
| 349 | + // From here we know that user is present |
| 350 | + return ( |
| 351 | + <div> |
| 352 | + <UserHeader name={user.name} avatar={user.avatar} /> |
| 353 | + <UserDetails email={user.email} phone={user.phone} /> |
| 354 | + {/* ... */} |
| 355 | + </div> |
| 356 | + ); |
| 357 | +}; |
| 358 | + |
| 359 | +// Components receive only the specific data they need |
| 360 | +const UserHeader: React.FC<{ name: string; avatar: string }> = ({ |
| 361 | + name, |
| 362 | + avatar, |
| 363 | +}) => ( |
| 364 | + <header> |
| 365 | + <img src={avatar} alt={name} /> |
| 366 | + <h1>{name}</h1> |
| 367 | + </header> |
| 368 | +); |
| 369 | +``` |
| 370 | + |
| 371 | +2. **Using nullish coalescing operators and default values in props**: |
| 372 | + |
| 373 | +```tsx |
| 374 | +type UserAvatarProps = { |
| 375 | + user?: User; |
| 376 | + size?: "small" | "medium" | "large"; |
| 377 | + fallbackImage?: string; |
| 378 | +}; |
| 379 | + |
| 380 | +const UserAvatar: React.FC<UserAvatarProps> = ({ |
| 381 | + user, |
| 382 | + size = "medium", |
| 383 | + fallbackImage = "/images/default-avatar.png", |
| 384 | +}) => { |
| 385 | + // Using optional chaining with fallback |
| 386 | + const avatarUrl = user?.avatarUrl ?? fallbackImage; |
| 387 | + const userName = user?.name ?? "Unknown User"; |
| 388 | + |
| 389 | + return ( |
| 390 | + <img |
| 391 | + src={avatarUrl} |
| 392 | + alt={`Avatar of ${userName}`} |
| 393 | + className={`avatar-${size}`} |
| 394 | + /> |
| 395 | + ); |
| 396 | +}; |
| 397 | +``` |
0 commit comments