Create API Contracts (GraphQL, tRPC, oRPC) and Workarounds

Introduction

Using TypeScript to ensure type safety in large frontend projects is a mature and common practice, but there is a vulnerability that can make the code fragile and chaotic: “API communication between services.”

While TypeScript can statically check the type of the code, APIs come from external sources, and any unexpected data might be thrown in. Real type safety can only be ensured by validating data formats at runtime.

Issues

Frontend developers typically rely on external documentation like Swagger🔗 or OpenAPI🔗 to understand the API formats, and they must describe data formats themselves in the frontend, often relies to type assertions.

// 1. Define type
type User = {
id: number
name: string
email: string
}
// 2. fetch API call
async function fetchUser(userId: number): Promise<User> {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
// 3. Assert the result of response.json() as User
const data = (await response.json()) as User
return data
}

Problem is that both the frontend and backend rely on “trust” regarding the API format, leading to potential runtime errors or impacting the development experience if responses do not meet expectations:

  • Backend changes causing response changes
  • Frontend developers cutting corners or rushing and skipping type definitions
  • Typos

Another issue is that even if the above problems do not occur, there is still redundancy in data format definitions between frontend and backend, requiring both sides to repeatedly describe the same data to ensure type safety.

To resolve these issues, it is best for communication between programs to have some form of enforceable and unified contract that defines a shared data format.

GraphQL

GraphQL Schema🔗 allows for automated code generation and type checking, enabling the frontend and backend to share a binding contract.

Here’s an example: define data → request data → receive data

type Project {
name: String
tagline: String
contributors: [User]
}
{
project(name: "GraphQL") {
tagline
}
}
{
"project": {
"tagline": "A query language for APIs"
}
}
  • Pros:
    • Unified schema definition that is language-agnostic, making it cross-language friendly.
    • Rich toolchain (type generation, automatic documentation, Playground).
    • Solves overfetching and underfetching issues.
  • Cons:
    • Concepts like caching, complexity, and a complete departure from traditional REST can impose significant burdens on legacy systems and habits.
    • Difficulty in managing query complexity (risk of over-fetching and N+1 problems).

RPC

RPC (Remote Procedure Call) is way of communication that allows the frontend to call backend methods as if they were local functions. For example, the frontend merely needs to call getUserById(123), and it automatically sends a request to the backend’s getUserById method, returning the response without manual handling of HTTP request/response details.

tRPC

An RPC framework that enables tight collaboration between frontend and backend using TypeScript.

Here’s an example: client calls → sends HTTP request → server finds procedure → returns JSON

const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({ url: 'http://localhost:3000/trpc' }),
],
});
await trpc.post.create.mutate({ title: "Hello" }); // Actually sends a POST request to http://localhost:3000/trpc/post.create
const appRouter = t.router({
post: t.router({
create: t.procedure.input(z.object({ title: z.string() }))
.mutation(({ input }) => db.post.create(input)),
}),
});
  • Pros:
    • Deep integration with TypeScript’s type system.
    • Shared API definitions between frontend and backend, eliminating the need for additional documentation or types maintenance.
  • Cons:
    • Concepts like caching, complexity, and a complete departure from traditional REST can impose significant burdens on legacy systems and habits.
    • High coupling between client and server, making it difficult to decouple.
    • Strongly tied to TypeScript’s type system, requiring the same TypeScript project structure to be shared.

oRPC

An RPC framework that enables tight collaboration between frontend and backend using OpenAPI.
openapi.yaml
paths:
/users/{id}:
get:
operationId: getUser
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
properties:
id:
type: integer
name:
type: string
// Automatically generated through codegen
import { getUser } from "./api-client";
const user = await getUser({ id: 123 });
// user type will automatically correspond to OpenAPI's User
  • Pros:

    • Uses OpenAPI standards, with good cross-language support.
    • Can automatically generate types and documentation through tooling.
    • API contracts are independent of implementation.
  • Cons:

    • Lacks the convenience of TypeScript’s immediate inference compared to tRPC.
    • The code generation process may require additional maintenance (e.g., CI/CD integration).
    • The community ecosystem is still developing.

Workarounds

Runtime Validation

If it’s not possible to unify types between frontend and backend through the above methods, we can still actively perform runtime checks on the frontend, using validators like zod🔗 to ensure type safety.

const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
const data = await response.json();
const parsed = UserSchema.parse(data);

Shared Types

If the frontend and backend share the same language (TypeScript), we can extract shared types into independent packages or compose them in a Monorepo or Monolith structure to directly share type definitions.

Further Reading