Simplifying TypeScript Backend Development: A Comprehensive Guide

December 2, 2023

16 min read

Simplifying TypeScript Backend Development: A Comprehensive Guide
Watch on YouTube

Simplifying TypeScript Backend Development: A Guide to Using Pure TypeScript with Express

In this article, we'll delve into dramatically simplifying TypeScript backend development, focusing on pure TypeScript without relying on multiple libraries or frameworks. Our strategy involves a single, small file importing Express, with other components being pure TypeScript functions. This approach isn't just easy to implement; it also improves interaction with the frontend. Having switched from a GraphQL API to this method, I can attest to its directness and appeal to front-end developers. However, it's crucial to identify the appropriate audience for our API. For a public API serving diverse clients, GraphQL or REST APIs are recommended. In contrast, for an API designed for a singular application, which is often the scenario, this technique proves to be highly effective. Let's get started.

Exploring Increaser's Codebase: How a Simplified API Middle Layer Enhances Front-End and Back-End Integration

In this tutorial, we'll examine Increaser's codebase, housed in a private repository. However, all reusable code pieces are accessible in the RadzionKit repository. Like many applications, our front-end app requires a backend to interact with databases or other server-side services. Direct database calls from a user's browser aren't feasible, necessitating an API middle layer. This layer processes requests from the browser, converts them into appropriate database queries, and returns results to the client. Communication between the browser and API occurs via the HTTP protocol. Commonly, REST or GraphQL formats facilitate this communication. However, we'll explore a simpler method: using the URL to specify a backend function name, and passing arguments to this function via the POST request body. Now, let's transition from theory to practice.

Mastering Monorepo Structures: Utilizing`api-interface for Streamlined API Method Management

In our monorepo structure, we define the API interface within a dedicated package named api-interface. The centerpiece of this package is the ApiInterface file, which enumerates every method our API offers. Differing from the GraphQL structure, our approach consolidates all methods in a single list, eschewing the typical division into queries and mutations.

import { OAuthProvider } from "@increaser/entities/OAuthProvider"
import { AuthSession } from "@increaser/entities/AuthSession"
import { Habit } from "@increaser/entities/Habit"
import { UserPerformanceRecord } from "@increaser/entities/PerformanceScoreboard"
import { Project } from "@increaser/entities/Project"
import { Subscription } from "@increaser/entities/Subscription"
import { Set, User } from "@increaser/entities/User"
import { ApiMethod } from "./ApiMethod"

export interface ApiInterface {
  authSessionWithEmail: ApiMethod<
    {
      code: string
      timeZone: number
    },
    AuthSession
  >

  authSessionWithOAuth: ApiMethod<
    {
      provider: OAuthProvider
      code: string
      redirectUri: string
      timeZone: number
    },
    AuthSession
  >

  user: ApiMethod<{ timeZone: number }, User>
  updateUser: ApiMethod<
    Partial<
      Pick<
        User,
        | "name"
        | "country"
        | "primaryGoal"
        | "focusSounds"
        | "tasks"
        | "weekTimeAllocation"
        | "goalToStartWorkAt"
        | "goalToFinishWorkBy"
        | "goalToGoToBedAt"
        | "isAnonymous"
        | "sumbittedHabitsAt"
      >
    >,
    undefined
  >
  manageSubscription: ApiMethod<
    undefined,
    {
      updateUrl: string
      cancelUrl: string
    }
  >

  subscription: ApiMethod<{ id: string }, Subscription | undefined>

  scoreboard: ApiMethod<
    { id: string },
    {
      id: string
      syncedAt: number
      myPosition?: number
      users: Omit<UserPerformanceRecord, "id">[]
    }
  >

  sendAuthLinkByEmail: ApiMethod<{ email: string }, undefined>

  createProject: ApiMethod<
    Omit<Project, "total" | "status" | "weeks" | "months">,
    Project
  >
  updateProject: ApiMethod<
    {
      id: string
      fields: Partial<
        Pick<
          Project,
          "name" | "color" | "status" | "emoji" | "allocatedMinutesPerWeek"
        >
      >
    },
    Project
  >
  deleteProject: ApiMethod<{ id: string }, undefined>

  redeemAppSumoCode: ApiMethod<{ code: string }, undefined>

  createHabit: ApiMethod<Omit<Habit, "successes">, Habit>
  updateHabit: ApiMethod<
    {
      id: string
      fields: Partial<
        Pick<
          Habit,
          "name" | "color" | "order" | "emoji" | "startedAt" | "successes"
        >
      >
    },
    Habit
  >
  deleteHabit: ApiMethod<{ id: string }, undefined>
  trackHabit: ApiMethod<{ id: string; date: string; value: boolean }, undefined>
  addSet: ApiMethod<Set, undefined>
  editLastSet: ApiMethod<Set, undefined>
  removeLastSet: ApiMethod<undefined, undefined>
}

export type ApiMethodName = keyof ApiInterface

The ApiMethod interface is utilized to define the input and output of each method in our API. In instances where a method lacks either input or output, we employ the undefined type. Frequently, we repurpose our existing entity models as input and output types, selectively choosing fields through the use of TypeScript's Pick or Omit utility types.

export interface ApiMethod<Input, Output> {
  input: Input
  output: Output
}

Implementing API Interfaces in Monorepos: The Role of ApiImplementation in Resolver Function Mapping

Within the api package of our monorepo, our task is to implement the interface defined in the api-interface package. To achieve this, we start by creating an ApiImplementation type. This type is situated in the resolvers folder inside our api package.

import { ApiInterface } from "@increaser/api-interface/ApiInterface"
import { ApiResolver } from "./ApiResolver"

export type ApiImplementation = {
  [K in keyof ApiInterface]: ApiResolver<K>
}

The ApiImplementation type functions as a mapping, where each method name is associated with a corresponding resolver function. These resolver functions are designed to receive an input that is associated with their specific method name and the context of a request. They are responsible for processing this information and returning the appropriate output.

import { ApiInterface } from "@increaser/api-interface/ApiInterface"
import { ApiResolverContext } from "./ApiResolverContext"

export interface ApiResolverParams<K extends keyof ApiInterface> {
  input: ApiInterface[K]["input"]
  context: ApiResolverContext
}

export type ApiResolver<K extends keyof ApiInterface> = (
  params: ApiResolverParams<K>
) => Promise<ApiInterface[K]["output"]>

Effective Error Handling in API Development: Utilizing ApiError and Resolver Functions

In the index.ts file located in the resolvers folder, we actualize the ApiImplementation type. This is accomplished by constructing a record that encompasses all the resolver functions of our API.

import { ApiImplementation } from "./ApiImplementation"
import { authSessionWithEmail } from "../auth/resolvers/authSessionWithEmail"
import { authSessionWithOAuth } from "../auth/resolvers/authSessionWithOAuth"
import { user } from "../users/resolvers/user"
import { updateUser } from "../users/resolvers/updateUser"
import { manageSubscription } from "../membership/subscription/resolvers/manageSubscription"
import { subscription } from "../membership/subscription/resolvers/subscription"
import { scoreboard } from "../scoreboard/resolvers/scoreboard"
import { sendAuthLinkByEmail } from "../auth/resolvers/sendAuthLinkByEmail"
import { createProject } from "../projects/resolvers/createProject"
import { updateProject } from "../projects/resolvers/updateProject"
import { deleteProject } from "../projects/resolvers/deleteProject"
import { redeemAppSumoCode } from "../membership/appSumo/resolvers/redeemAppSumoCode"
import { createHabit } from "../habits/resolvers/createHabit"
import { updateHabit } from "../habits/resolvers/updateHabit"
import { deleteHabit } from "../habits/resolvers/deleteHabit"
import { trackHabit } from "../habits/resolvers/trackHabit"
import { addSet } from "../sets/resolvers/addSet"
import { editLastSet } from "../sets/resolvers/editLastSet"
import { removeLastSet } from "../sets/resolvers/removeLastSet"

export const implementation: ApiImplementation = {
  authSessionWithEmail,
  authSessionWithOAuth,
  sendAuthLinkByEmail,
  user,
  updateUser,
  manageSubscription,
  subscription,
  scoreboard,
  createProject,
  updateProject,
  deleteProject,
  redeemAppSumoCode,
  createHabit,
  updateHabit,
  deleteHabit,
  trackHabit,
  addSet,
  editLastSet,
  removeLastSet,
}

Consider this example of a resolver for the updateProject method. This resolver extracts the user ID from the context and utilizes it to update the corresponding user's projects in the database.

import { assertUserId } from "../../auth/assertUserId"
import * as projectsDb from "@increaser/db/project"
import { ApiResolver } from "../../resolvers/ApiResolver"

export const updateProject: ApiResolver<"updateProject"> = async ({
  input,
  context,
}) => {
  const userId = assertUserId(context)

  const { id, fields } = input

  return projectsDb.updateProject(userId, id, fields)
}

The assertUserId function is designed to verify the presence of a user ID in the context. If the user ID is found, the function returns it; otherwise, it triggers an ApiError.

import { ApiError } from "@increaser/api-interface/ApiError"
import { ApiResolverContext } from "../resolvers/ApiResolverContext"

export const assertUserId = ({ userId }: ApiResolverContext) => {
  if (!userId) {
    throw new ApiError(
      "invalidAuthToken",
      "Only authenticated user can perform this action"
    )
  }

  return userId
}

The ApiError class, situated within the api-interface package, serves a crucial role in our API's error handling. It categorizes and communicates the nature of errors that occur during API calls. On the front-end, these errors are then handled appropriately. For example, if the assertUserId function identifies an invalidAuthToken error, the front-end logic will respond by redirecting the user to the login page.

export type ApiErrorId = "invalidAuthToken" | "invalidInput" | "unknown"

export class ApiError extends Error {
  constructor(public readonly id: ApiErrorId, public readonly message: string) {
    super(message)
  }
}

Streamlining Express Integration: Routing, Context Extraction, and Resolver Invocation in Key API File

In this key file where we integrate Express, we begin by initializing a router and setting it up with a JSON parser. We proceed by iterating through our API methods. For each, we set up a dedicated route, handle the input processing, extract the necessary context, and then call the respective resolver function.

import express, { Router } from "express"
import cors from "cors"
import { implementation } from "./resolvers"
import { getErrorMessage } from "@increaser/utils/getErrorMessage"
import { ApiResolverParams } from "./resolvers/ApiResolver"
import { getResolverContext } from "./resolvers/utils/getResolverContext"
import { ApiError } from "@increaser/api-interface/ApiError"
import { reportError } from "./utils/reportError"
import { pick } from "@increaser/utils/record/pick"
import { ApiMethodName } from "@increaser/api-interface/ApiInterface"

const router = Router()

router.use(express.json())

Object.entries(implementation).forEach(([endpoint, resolver]) => {
  router.post(`/${endpoint}`, async (req, res) => {
    const input = req.body
    const context = await getResolverContext(req)

    try {
      const resolverParams: ApiResolverParams<ApiMethodName> = {
        input,
        context,
      }

      const response = await resolver(resolverParams as never)
      res.json(response)
    } catch (err) {
      const isApiError = err instanceof ApiError
      if (!isApiError) {
        reportError(err, { endpoint, input, context })
      }

      const response = pick(
        isApiError ? err : new ApiError("unknown", getErrorMessage(err)),
        ["id", "message"]
      )

      res.status(400).json(response)
    }
  })
})

export const app = express()

app.use(cors())

app.use("/", router)

For transforming request headers into a resolver context, we employ the getResolverContext function. Leveraging our CloudFront setup, this allows us to extract the country code directly from the cloudfront-viewer-country header. Additionally, we retrieve the user's JWT token from the authorization header.

import { IncomingHttpHeaders } from "http"
import { ApiResolverContext } from "../ApiResolverContext"
import { CountryCode } from "@increaser/utils/countries"
import { userIdFromToken } from "../../auth/userIdFromToken"
import { safeResolve } from "@increaser/utils/promise/safeResolve"
import { extractHeaderValue } from "../../utils/extractHeaderValue"

interface GetResolverContextParams {
  headers: IncomingHttpHeaders
}

export const getResolverContext = async ({
  headers,
}: GetResolverContextParams): Promise<ApiResolverContext> => {
  const country = extractHeaderValue<CountryCode>(
    headers,
    "cloudfront-viewer-country"
  )
  const token = extractHeaderValue(headers, "authorization")
  const userId = token
    ? await safeResolve(userIdFromToken(token), undefined)
    : undefined

  return {
    country,
    userId,
  }
}

The extractHeaderValue function acts as a straightforward helper designed to retrieve a header value from a request. It's important to note that in the headers object, all keys are in lowercase. Therefore, we must convert the header name to lowercase before attempting to access its value.

import { IncomingHttpHeaders } from "http"

export const extractHeaderValue = <T extends string>(
  headers: IncomingHttpHeaders,
  name: string
): T | undefined => {
  const value = headers[name.toLowerCase()]
  if (!value) return undefined

  return (Array.isArray(value) ? value[0] : value) as T
}

The safeResolver is another small helper function that will return a fallback value if the provided promise rejects.

export const safeResolve = async <T>(
  promise: Promise<T>,
  fallback: T
): Promise<T> => {
  try {
    const result = await promise
    return result
  } catch {
    return fallback
  }
}

The userIdFromToken function is responsible for extracting the user ID from the JWT token. It does so by verifying the token's signature and returning the decoded user ID.

import jwt from "jsonwebtoken"
import { getSecret } from "../utils/getSecret"

interface DecodedToken {
  id: string
}

export const userIdFromToken = async (token: string) => {
  const secret = await getSecret("SECRET")

  const decoded = jwt.verify(token, secret)

  return decoded ? (decoded as DecodedToken).id : undefined
}

When encountering an error while resolving a request, we initially determine if it's an ApiError. If not, this indicates an unexpected issue, which we then report to Sentry. Furthermore, for unexpected errors, we also return an ApiError, but with ID set to unknown. This approach ensures that the front-end can consistently discern whether the error originated from the API by checking the ID field. If an error lacks an ID, it suggests a network-level problem, not an API issue.

Enhancing Front-End Integration: Utilizing callApi for Efficient Communication with Backend API

Let's shift our focus to the front-end to explore how this API can be utilized. Within the app package of our monorepo, there is an api folder. The core component of this folder is the callApi function. This function accepts the base URL of our API, a method name, input parameters, and an optional authentication token. It returns a promise that resolves with the output of the specified API method.

import { ApiError } from "@increaser/api-interface/ApiError"
import {
  ApiMethodName,
  ApiInterface,
} from "@increaser/api-interface/ApiInterface"
import { asyncFallbackChain } from "@increaser/utils/promise/asyncFallbackChain"
import { joinPaths } from "@increaser/utils/query/joinPaths"
import { safeResolve } from "@increaser/utils/promise/safeResolve"

interface CallApiParams<M extends ApiMethodName> {
  baseUrl: string
  method: M
  input: ApiInterface[M]["input"]
  authToken?: string
}

export const callApi = async <M extends ApiMethodName>({
  baseUrl,
  method,
  input,
  authToken,
}: CallApiParams<M>): Promise<ApiInterface[M]["output"]> => {
  const url = joinPaths(baseUrl, method)

  const headers: HeadersInit = {
    "Content-Type": "application/json",
  }
  if (authToken) {
    headers["Authorization"] = authToken
  }

  const response = await fetch(url, {
    method: "POST",
    headers,
    body: JSON.stringify(input),
  })

  if (!response.ok) {
    const error = await asyncFallbackChain<Error>(
      async () => {
        const result = await response.json()
        if ("id" in result) {
          return new ApiError(result.id, result.message)
        }
        return new Error(JSON.stringify(result))
      },
      async () => {
        const message = await response.text()
        return new Error(message)
      },
      async () => new Error(response.statusText)
    )

    throw error
  }

  return safeResolve(response.json(), undefined)
}

To construct a URL for an API call, we utilize the joinPaths function. This function merges the baseUrl and the method name, ensuring that there are no duplicate slashes.

export const joinPaths = (base: string, path: string): string => {
  if (base.endsWith("/")) {
    base = base.slice(0, -1)
  }

  if (path.startsWith("/")) {
    path = path.substring(1)
  }

  return `${base}/${path}`
}

Next, we construct the headers for our request. We set Content-Type to application/json. Additionally, if an authentication token is available, it is included in the headers. We then utilize the native fetch function to make an API call using the POST method, passing the input as the request body.

Handling API Errors in Front-End Applications: Implementing asyncFallbackChain for Robust Error Processing

If our API returns an error, the response.ok property will be set to false. In such cases, we try to extract the error message from the response body using the asyncFallbackChain function. This helper function sequentially executes the provided functions, returning the result of the first successful execution without errors. If all functions fail, it throws the error from the last attempted function.

export const asyncFallbackChain = async <T,>(
  ...functions: (() => Promise<T>)[]
): Promise<T> => {
  try {
    const result = await functions[0]()
    return result
  } catch (error) {
    if (functions.length === 1) {
      throw error
    }

    return asyncFallbackChain(...functions.slice(1))
  }
}

Initially, we attempt to extract the error message using the response.json method. If an id field is present in the response, we construct an ApiError instance identical to the one used on the API side. In the absence of an id field, we return a generic error with the response body as its message. However, if parsing the JSON response fails, we then resort to the response.text method. Although this method is unlikely to fail, as a precaution, we have response.statusText as the final fallback.

Finally, we employ the safeResolve helper once more. This is because our API may sometimes return no response, causing response.json to fail. By wrapping response.json with safeResolve, the function returns undefined instead of throwing an error in such scenarios.

Leveraging React Hooks for API Integration: Introducing useApi for Simplified API Calls in React Components

To facilitate comfortable interaction with the API from a React component, we require hooks. The primary one is the useApi hook, which will return an object with a call function that we can invoke to make an API call.

import {
  ApiInterface,
  ApiMethodName,
} from "@increaser/api-interface/ApiInterface"
import { useCallback } from "react"
import { callApi } from "../utils/callApi"
import { shouldBeDefined } from "@increaser/utils/shouldBeDefined"
import { useAuthSession } from "auth/hooks/useAuthSession"
import { ApiError } from "@increaser/api-interface/ApiError"

const baseUrl = shouldBeDefined(process.env.NEXT_PUBLIC_API_URL)

export const useApi = () => {
  const [authSession, setAuthSession] = useAuthSession()
  const authToken = authSession?.token

  const call = useCallback(
    async <M extends ApiMethodName>(
      method: M,
      input: ApiInterface[M]["input"]
    ) => {
      try {
        const result = await callApi({
          baseUrl,
          method,
          input,
          authToken,
        })

        return result
      } catch (err) {
        if (err instanceof ApiError && err.id === "invalidAuthToken") {
          setAuthSession(undefined)
        }
        throw err
      }
    },
    [authToken, setAuthSession]
  )

  return { call } as const
}

In this setup, we retrieve the baseUrl from the environment variables. As our application is a NextJS project, we prefix it with NEXT_PUBLIC_. We then utilize the useAuthSession hook to obtain the authentication session and a function to modify this session. For more information about authentication in NextJS or React applications, consider reading my articles on OAuth authentication and Magic link authentication.

The call function, similar in signature to the callApi function, omits the baseUrl and authToken parameters, since these are supplied by our hook. Upon encountering an error, the function checks if it is an ApiError with an invalidAuthToken ID. If this is the case, it clears the auth session. This method is effective for managing situations where the user's token is either expired or invalid.

Streamlining Data Fetching in React: Introducing useApiQuery Hook for Simplified API Queries

To enhance our developer experience, we've implemented the useApiQuery hook. This hook accepts a method name and input, returning a React Query. Additionally, this file includes a helper function, getApiQueryKey, designed to generate a query key for React Query. This key can be subsequently used to invalidate and refetch queries impacted by mutations.

import { withoutUndefined } from "@increaser/utils/array/withoutUndefined"
import { useQuery } from "react-query"
import { useApi } from "./useApi"
import {
  ApiInterface,
  ApiMethodName,
} from "@increaser/api-interface/ApiInterface"

export const getApiQueryKey = <M extends ApiMethodName>(
  method: M,
  input: ApiInterface[M]["input"]
) => withoutUndefined([method, input])

export const useApiQuery = <M extends ApiMethodName>(
  method: M,
  input: ApiInterface[M]["input"]
) => {
  const { call } = useApi()

  return useQuery(getApiQueryKey(method, input), () => call(method, input))
}

Observe the ease with which we can query any data from our API using just a single line, as demonstrated in this example where we query a scoreboard of the most productive users.

const query = useApiQuery("scoreboard", { id: scoreboardPeriod })

Simplifying State Management in React: Utilizing useApiMutation Hook for Efficient API Mutations

Lastly, there is the useApiMutation hook. This hook takes a method name and optional options, and returns a React Query mutation.

import {
  ApiInterface,
  ApiMethodName,
} from "@increaser/api-interface/ApiInterface"
import { useMutation } from "react-query"
import { useApi } from "./useApi"

interface ApiMutationOptions<M extends ApiMethodName> {
  onSuccess?: (data: ApiInterface[M]["output"]) => void
  onError?: (error: unknown) => void
}

export const useApiMutation = <M extends ApiMethodName>(
  method: M,
  options: ApiMutationOptions<M> = {}
) => {
  const api = useApi()

  return useMutation(
    (input: ApiInterface[M]["input"]) => api.call(method, input),
    options
  )
}

Here is an example demonstrating how we can utilize this hook to update a user's profile, followed by providing a callback to refetch the scoreboards upon successful update.

const refetch = useRefetchQueries()
const { mutate: updateUser } = useApiMutation("updateUser", {
  onSuccess: () => {
    refetch(
      ...scoreboardPeriods.map((id) => getApiQueryKey("scoreboard", { id }))
    )
  },
})
// ...
updateUser(newFields)