🐙 GitHub
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.
`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[]
}
sendAuthLinkByEmail: ApiMethod<{ email: string }, undefined>
createProject: ApiMethod<
Omit,
Project
updateProject: ApiMethod<
{
id: string
fields: Partial<
Pick<
Project,
"name" | "color" | "status" | "emoji" | "allocatedMinutesPerWeek"
>
>
},
ProjectdeleteProject: ApiMethod<{ id: string }, undefined>
redeemAppSumoCode: ApiMethod<{ code: string }, undefined>
createHabit: ApiMethod, 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
editLastSet: ApiMethod
removeLastSet: ApiMethod
}
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 = {
}
`
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 {
input: ApiInterface[K]["input"]
context: ApiResolverContext
}
export type ApiResolver = (
params: ApiResolverParams
) => Promise
`
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 => {
const country = extractHeaderValue(
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 = (
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 {
baseUrl: string
method: M
input: ApiInterface[M]["input"]
authToken?: string
}
export const callApi = async ({
baseUrl,
method,
input,
authToken,
}: CallApiParams): Promise => {
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(
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 (
...functions: (() => Promise)[]
): Promise => {
try {
const result = await functions0
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 (
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.
`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 = (
method: M,
input: ApiInterface[M]["input"]
) => withoutUndefined([method, input])
export const useApiQuery = (
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 {
onSuccess?: (data: ApiInterface[M]["output"]) => void
onError?: (error: unknown) => void
}
export const useApiMutation = (
method: M,
options: ApiMutationOptions = {}
) => {
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)
Top comments (0)