This is a non-exhaustive list of the coding patterns the WorkWave RouteManager's front-end team follows. The patterns are based on years of experience writing, debugging, and refactoring front-end applications with React and TypeScript but evolves constantly. Most of the possible improvements and the code smells are detected during the code reviews and the pair programming sessions.
(last update: 2023, April)
- Always use Type-Only Imports and Exports
- Prefer @ts-expect-error over @ts-ignore
- Take advantage of Types Inference as much as possible
- Long Generic names
- Avoid TypeScript guards
- Use Array.reduce's Generic
- Prefer discriminated unions over optional keys
- Prefer local types over inline ones
- Avoid type assertions (as)
- Apply ts-expect-error to the smallest possible context
Always use Type-Only Imports and Exports
You must import types through import type
and re-export types through export type
.
// ❌ don't
import { Categories } from .'/types'
export { Props as ButtonProps } from .'/types'
// ✅ do
import type { Categories } from .'/types'
export type { Props as ButtonProps } from .'/types'
Prefer @ts-expect-error over @ts-ignore
@ts-ignore
s are banned through an ESLint rule, @ts-expect-error
offers a greater experience and scales better in case you are working around a TypeScript limitations or a type inconsistency. Please. always leave a comment to explain why you are using @ts-expect-error
.
// ❌ don't
// @ts-ignore
delete mapSelectionAtom.context.category
// ✅ do
// @ts-expect-error category is not valid on `none` but we need to ensure is removed from the object
delete mapSelectionAtom.context.category
Take advantage of Types Inference as much as possible
Write JavaScript the most natural way, don't add types where TypeScript can infer them. Types reduce readability and increase the number of imports.
// ❌ don't
const a:number = 1
const b = useState<number>(1)
const c = [1, 2, 3].map<number>(item => item*2)
// ✅ do
const a = 1
const b = useState(1)
const c = [1, 2, 3].map(item => item*2)
// ❌ don't
function isEven(a: number): boolean {
const result:boolean = a % 2
return result
}
// ✅ do
function isEven(a: number) {
return a % 2 === 0
}
Long Generic names
Every case is different and must be treated separately but, as a rule of thumb, avoid Generics. If you must use them, please avoid using T
, K
, etc. but prefer explicit names.
// ❌ don't
export type UseFormFieldOptions<
T extends Record<string, FormField>,
K extends FormError<keyof T>
> = {
// ...
}
// ✅ do
export type UseFormFieldOptions<
FIELDS extends Record<string, FormField>,
ERROR extends FormError<keyof FIELDS>
> = {
// ...
}
Avoid TypeScript guards
TypeScript guards are useful when the data comes from external, untyped, sources. Internal, controlled, code must prefer simpler ways to help generic functions.
type Tag = { id: string }
type Info = { uid: string }
type GpsEntity = Tag | Info
// ❌ don't
export function getGpsEntityId<T extends GpsEntity>(entity: T) {
if (isTag(entity)) {
return entity.id
}
if (isInfo(entity)) {
return entity.uid
}
}
// ✅ do
export function getGpsEntityId(entity: unknown, type: 'tag' | 'info') {
switch (type) {
case 'tag':
return (entity as Tag).id
case 'info':
return (entity as Info).uid
}
}
additionally, you can use a map of categories and types that allows TypeScript to protect the developer from passing a wrong entity
.
// ✅ do
type GpsCategory = 'tag' | 'info'
interface EntityForGpsCategory extends Record<GpsCategory, unknown> {
tag: Tag
info: Info
}
export function getGpsEntityId<Category extends GpsCategory>(
entity: EntityForGpsCategory[Category],
category: Category,
) {
switch (type) {
case 'tag':
return entity.id
case 'info':
return entity.uid
}
}
Use Array.reduce's Generic
Always take advantage of the Array.reduce
's Generic type instead of typing the parameters passed to it.
// ❌ don't
[1, 2, 3].reduce((acc: number, item) => {
return acc + item
}, 0)
// ✅ do
[1, 2, 3].reduce<number>((acc, item) => {
return acc + item
}, 0)
Prefer discriminated unions over optional keys
Optional keys save you from runtime errors but aren't expressive, Discriminated Unions are.
// ❌ don't
type Order = {
status: 'ready' | 'inProgress' | 'complete'
name: string
at?: Location
expectedDelivery?: Date
deliveredAt?: Date
proofOfDeliveries?: Proof[]
}
function getEmailMessage(order: Order) {
if(order.at) {
return `${order.name} is at ${order.at}`
}
if(order.expectedDelivery) {
return `${order.name} will be delivered ${order.expectedDelivery}`
}
if(order.deliveredAt) {
return `${order.name} has been delivered at ${order.deliveredAt}`
}
}
// ✅ do
type Order = {
name: string
} & ({
status: 'ready'
at: Location
} | {
status: 'inProgress'
expectedDelivery: Date
} | {
status: 'complete'
deliveredAt: Date
proofOfDeliveries: Proof[]
})
function getEmailMessage(order: Order) {
switch(order.status) {
case 'ready':
return `${order.name} is at ${order.at}`
case 'inProgress':
return `${order.name} will be delivered ${order.expectedDelivery}`
case 'complete':
return `${order.name} has been delivered at ${order.deliveredAt}`
}
}
Prefer local types over inline ones
Inline types lead to hard-to-read code, prefer type local to the module instead.
// ❌ don't
function foo(callback: (bar: Bar, baz: Baz) => boolean) {
/* ... rest of the code... */
}
// ✅ do
type FooCallback = (bar: Bar, baz: Baz) => boolean
function foo(callback: FooCallback) {
/* ... rest of the code... */
}
Avoid type assertions (as)
Type assertions are a big mistake of 99% of the times that bring to problematic consequences.
// ❌ don't
const requestBody = { type: 'update_scope_of_collection_in_allowlist' as AllowedMetadataTypes }
// ✅ do
const type: AllowedMetadataTypes = 'update_scope_of_collection_in_allowlist';
const requestBody = { type }
// ✅ do
const requestBody = { type: 'update_scope_of_collection_in_allowlist' } as const
Instead, when we 100% need to use it, prefer @ts-expect-error
with an explanation.
// ✅ do
const requestBody = {
// @ts-expect-error at the time of writing, AllowedMetadataTypes is controlled by an external module that's outdated
// even if the below code works correctly.
type: 'update_scope_of_collection_in_allowlist' as AllowedMetadataTypes
}
Here you can find a more comprehensive article about the topic.
Apply ts-expect-error to the smallest possible context
@ts-expect-error
must be applied to the smallest possible scope to TS accepting unintended errors.
// ❌ don't
// @ts-expect-error TS 4.5.2 does not infer correctly the type of typedChildren.
return React.cloneElement(typedChildren, htmlAttributes);
// ✅ do
return React.cloneElement(
// @ts-expect-error TS 4.5.2 does not infer correctly the type of typedChildren.
typedChildren,
htmlAttributes
);