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.
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.
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
}
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"]>
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)
}
}
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.
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.
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.
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.
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 })
useApiMutation
Hook for Efficient API MutationsLastly, 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)