Simplifying TypeScript Backend Development: A Comprehensive Guide
Tags: typescript, backenddevelopment, api, express
Published: 2023-12-25 04:53:56

{% embed https://youtu.be/BDGR2ji5DWg %}

🐙 [GitHub](https://github.com/radzionc/radzionkit)

## 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](https://increaser.org/)'s codebase, housed in a private repository. However, all reusable code pieces are accessible in the [RadzionKit](https://github.com/radzionc/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.

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

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

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

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

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

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

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

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

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

```tsx
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.

```tsx
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.

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

```tsx
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.

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

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

```tsx
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.

```ts
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](https://radzion.com/blog/oauth) and [Magic link authentication](https://radzion.com/blog/magic).

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.

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

```tsx
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.

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

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