Streamline Your Typescript Projects with These Useful Abstract Utils and Helpers

September 9, 2023

7 min read

Streamline Your Typescript Projects with These Useful Abstract Utils and Helpers
Watch on YouTube

In this post, I aim to share some of the most useful abstract utilities and helper functions that I use across different TypeScript projects. I believe these will save you time and help you to think about your code in a more abstract way. You can find all these functions in the utils package under the RadzionKit repository.

match: A functional switch statement

The first function I want to introduce is match. It's a more refined version of the switch-case statement.

export function match<T extends string | number | symbol, V>(
  value: T,
  handlers: { [key in T]: () => V }
): V {
  const handler = handlers[value]

  return handler()
}

Here's an example demonstrating how I use it to construct variants of the Button component based off of the kind prop:

  ${({ kind }) =>
    match(kind, {
      primary: () => css`
        background: ${getColor('primary')};
        color: ${getColor('white')};
      `,
      secondary: () => css`
        background: ${getColor('mist')};
        color: ${getColor('contrast')};
      `,
      reversed: () => css`
        background: ${getColor('contrast')};
        color: ${getColor('background')};
      `,
      alert: () => css`
        background: ${getColor('alert')};
        color: ${getColor('white')};
      `,
      outlined: () => css`
        border: 1px solid ${getColor('mistExtra')};
        color: ${getColor('contrast')};
      `,
      outlinedAlert: () => css`
        border: 1px solid ${getColor('alert')};
        color: ${getColor('alert')};
      `,
      ghost: () => css`
        color: ${getColor('contrast')};
      `,
      ghostSecondary: () => css`
        color: ${getColor('textSupporting')};
      `,
    })}

The match function takes two arguments: a value to match against, and an object mapping each possible value to a function. The function that corresponds to the value is then called, and its result is returned. We typically use match for union types, and it will flag a TypeScript error if you forget to provide a resolver for one of the possible values.

shouldBeDefined: throw an error if value is undefined

The next function, shouldBeDefined, is designed to throw an error if the value is undefined. Sometimes we find ourselves in situations where we know a value is defined, but the type could be undefined. In these scenarios, we don't need to handle undefined values explicitly but simply need to throw an error if the value is missing.

export function shouldBeDefined<T>(
  value: T | undefined,
  valueName: string = "value"
): T {
  if (value === undefined) {
    throw new Error(`${valueName} is undefined`)
  }

  return value
}

One example is accessing environment variables. Their type is string | undefined, but we know that they were established before launching our application.

const apiUrl = shouldBeDefined(process.env.NEXT_PUBLIC_API_URL)

getRecord: convert array to an object

The third function, getRecord, is used to convert an array into an object.

export function getRecord<T>(
  items: T[],
  getId: (item: T) => string
): Record<string, T> {
  const record: Record<string, T> = {}

  items.forEach((item) => {
    record[getId(item)] = item
  })

  return record
}

At Increaser, I use it to convert a list of projects into a record where the key is the project id. This allows for easier access to project-related information by id, compared to searching using the find method on an array.

const projectsRecord = getRecord(projects, (project) => project.id)

memoize: cache function results

The fourth function, memoize, caches the result of a function based on its arguments. If the same function is called with the same argument a second time, it will return the previous result without re-evaluating the function.

export const memoize = <T extends (...args: any[]) => any>(
  func: T,
  getKey?: (...args: any[]) => string
): T => {
  const cache = new Map<string, ReturnType<T>>()

  const memoizedFunc = (...args: Parameters<T>) => {
    const key = getKey ? getKey(...args) : JSON.stringify(args)

    const cachedResult = cache.get(key)

    if (!cachedResult) {
      const result = func(...args)
      cache.set(key, result)

      return result
    }

    return cachedResult
  }

  return memoizedFunc as T
}

Usually, I use this to avoid initializing the same server twice or repeating requests for data that hasn't changed. While this implementation is quite simple, it can be enhanced by adding a cache expiration time, for instance.

toPercents: convert number to percents

The fifth function, toPercents, converts a number to a percentage.

type Format = "round"

export const toPercents = (value: number, format?: Format) => {
  const number = value * 100

  return `${format === "round" ? Math.round(number) : number}%`
}

This function is extremely useful in two scenarios: One, to display a ratio rounded to the nearest percent to users. Second, it helps when converting a number to a percentage value for CSS properties, especially for absolutely-positioned elements.

splitBy: split array into chunks

An interesting function in the array directory is splitBy. It divides an array into chunks.

export function splitBy<T>(items: T[], organize: (item: T) => 0 | 1) {
  const result: [T[], T[]] = [[], []]

  items.forEach((item) => {
    const bucket = result[organize(item)]
    bucket.push(item)
  })

  return result
}

Here's an example of it being used to divide color options into 'used' and 'free' sections in the color picker component.

const [free, used] = splitBy(colors, (value) => (usedValues.has(value) ? 1 : 0))

withoutDuplicates: remove duplicates from an array

Another useful array function is withoutDuplicates, which takes an array and a function to compare two items.

export function withoutDuplicates<T>(
  items: T[],
  areEqual: (a: T, b: T) => boolean = (a, b) => a === b
): T[] {
  const result: T[] = []

  items.forEach((item) => {
    if (!result.find((i) => areEqual(i, item))) {
      result.push(item)
    }
  })

  return result
}

range: create an array of a given length

The last array function I want to discuss is range; it produces an array of a specified length.

export const range = (length: number) => Array.from({ length }, (_, i) => i)

This is useful for instances like the bar chart component that illustrates how much you worked on a specific day. Here, we make an array with a length of 7 and populate it with data for the bar chart.

result = range(D_IN_WEEK).map((index) => {
  const dayStartsAt = weekStartedAt + MS_IN_DAY * index
  const dayEndsAt = dayStartsAt + MS_IN_DAY
  const value =
    getSetsSum(
      currentWeekSets.filter(
        (set) => set.end < dayEndsAt && set.start > dayStartsAt
      )
    ) / MS_IN_SEC

  return {
    isCurrent: index === currentWeekday,
    value,
    label: getShortWeekday(index),
  }
})

convertDuration: convert between time units

A major part of my work with Increaser involves numerous time-tracking features, so I've organized all the time-related functions in the time directory. An interesting function here is convertDuration, which converts between different time units.

import { MS_IN_DAY, MS_IN_HOUR, MS_IN_MIN, MS_IN_SEC } from "."

export type DurationUnit = "ms" | "s" | "min" | "h" | "d"

const msInUnit: Record<DurationUnit, number> = {
  ms: 1,
  s: MS_IN_SEC,
  min: MS_IN_MIN,
  h: MS_IN_HOUR,
  d: MS_IN_DAY,
}

export const convertDuration = (
  value: number,
  from: DurationUnit,
  to: DurationUnit
) => {
  const result = value * (msInUnit[from] / msInUnit[to])

  return result
}

For instance, I may have a value expressed in minutes, but I want to display it in hours. By using convertDuration, I can pass the value, min as the from unit, and h as the to unit.

<Text weight="bold">
  {Math.round(convertDuration(workBudgetInMin, "min", "h"))}h / week
</Text>

inTimeZone: convert date to a different timezone

The last time-related function I want to share is inTimeZone, which adapts a time as if it was in a different timezone.

import { getCurrentTimezoneOffset } from "./getCurrentTimezoneOffset"
import { convertDuration } from "./convertDuration"

export const inTimeZone = (timestamp: number, targetTimeZoneOffset: number) => {
  const offsetDiff = targetTimeZoneOffset - getCurrentTimezoneOffset()
  return timestamp + convertDuration(offsetDiff, "min", "ms")
}

At Increaser, I manage a scoreboard of the most productive users of the month. However, these users are located in various time zones, which means their month starts at different times. On the backend, I gain the start of the current month in the server's timezone and then use inTimeZone to get the start of the month in the user's timezone. This allows me to filter their sessions by the current month.

const now = Date.now()
const monthStartedAt = inTimeZone(getMonthStartedAt(now), timeZone)
const monthStartedAt = inTimeZone(getMonthStartedAt(now), timeZone)
const currentMonthSets = sets.filter((set) => set.start > monthStartedAt)

There are many more handy functions available at RadzionKit, but this should suffice for now. In upcoming posts, I will delve into essential React components and hooks.