DEV Community

Stefano Magni
Stefano Magni

Posted on • Edited on

RouteManager UI coding patterns: TypeScript

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

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'
Enter fullscreen mode Exit fullscreen mode

Prefer @ts-expect-error over @ts-ignore

@ts-ignores 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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>
> = {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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}`

  }
}
Enter fullscreen mode Exit fullscreen mode

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... */
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

Top comments (0)