Skip to content

Commit cd299da

Browse files
committed
feat: typescript-guidelines
1 parent c89e607 commit cd299da

File tree

1 file changed

+397
-0
lines changed

1 file changed

+397
-0
lines changed

guides/typescript-guidelines.md

Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
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

Comments
 (0)