RouteManager UI coding patterns: TypeScript
Published: 2021-07-20 06:32:00


> This is a non-exhaustive list of the coding patterns the [WorkWave RouteManager's](https://www.workwave.com/route-manager/) **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](#always-use-typeonly-imports-and-exports)
- [Prefer @ts-expect-error over @ts-ignore](#prefer-tsexpecterror-over-tsignore)
- [Take advantage of Types Inference as much as possible](#take-advantage-of-types-inference-as-much-as-possible)
- [Long Generic names](#long-generic-names)
- [Avoid TypeScript guards](#avoid-typescript-guards)
- [Use Array.reduce's Generic](#use-arrayreduces-generic)
- [Prefer discriminated unions over optional keys](#prefer-discriminated-unions-over-optional-keys)
- [Prefer local types over inline ones](#prefer-local-types-over-inline-ones)
- [Avoid type assertions (as)](#avoid-type-assertions-as)
- [Apply ts-expect-error to the smallest possible context](#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`.

```ts
// ❌ 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`.

```ts
// ❌ 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.

```ts
// ❌ 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.
```ts
// ❌ 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.
```ts
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`.

```ts
// ✅ 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.
```ts
// ❌ 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.

```ts
// ❌ 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.

```ts
// ❌ 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.

```ts
// ❌ 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.

```ts
// ✅ 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](https://tkdodo.eu/blog/the-power-of-const-assertions).

## 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.

```ts
// ❌ 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
);
```