You don't need GraphQL or tRPCYou don't need GraphQL or tRPC


This is not a library or framework. It is rather a simple solution to implement fullstack typesafe APIs without external dependencies.

You can have the cake and eat it too.

End-to-end type-safe APIs without dependencies or ecosystem buy-in. Just your REST APIs, some TypeScript, and a happy developer.

We only want effortless, type-safe APIs all the way down the stack. This is how it should work:

And that indeed is how it works!

The concept is very simple: TypeScript API controllers that expose all needed typing information and runtime type safety thanks to standard JSON Schemas.

Links to the repo and CodeSandbox are at the end of the article.

The Background

With every year that passes in our exciting little bubble of web development, it seems to me that we come closer and closer to the final end goal: The coupling of stellar developer experience, stability, speed of development, and the effortless expression of business needs using code and infrastructure.

This might also just be the natural path every software engineer wanders throughout their career. Or a mixture of both in a subculture like ours where we reinvent the same things over and over again, for good (reinventions bring the potential for radical innovation).

I remember well that beginner-dev me, when building fullstack apps for the first time, using good ol' Express, wanted to find a way to unify type information (from database schemas down to the frontend), so that repeating that information in every part of the stack could be avoided. I was playing around with every tool I could find and also committed a few crimes along the way. But I guess the only developer out there who isn't, is the one who never coded.

Then GraphQL came. And I still remain convinced that this is one of the most misused technologies in recent years, but it has shifted the whole community in the right direction. In my opinion, the great hype and adoption of GraphQL came to be because it provides a type system for APIs. I know that that's just one of the features of GraphQL and definitely not the reason it was invented, but it was the reason why it became so popular. Generating TypeScript types or SDKs for every programming language - a game changer. Sprinkling a few million bucks of marketing on it, calling your company like the moon landing, and dang 💥 - people realize that having typed API clients is a good thing, and so everyone switches their APIs to GraphQL.

Don't get me wrong - GraphQL is amazing and has its valid use cases, but for most APIs, a well-documented REST API is probably the better choice.

Providing fully typed-out APIs isn't something GraphQL invented - in fact, we had that already decades before - SOAP & WSDL, Swagger, and OpenAPI later.

With TypeScript surging in popularity, for good reason, this aspect became more and more important. Knowing the exact request and response types of the APIs you are calling without having to maintain them yourself is a huge productivity boost.

Then tRPC was born. The logical successor of SOAP, OpenAPI, and GraphQL.

tRPC is the synthesis of the learnings of the past - and applies all the good we gained from the predecessors, while weeding out the dark sides (code generation, outdated dependencies, vendor lock-in). It's the next logical step for fullstack TypeScript development. Why would you want to generate types for an API which already has all the types defined, in the same programming language, heck, most of the times even in the same repo?

So when I first saw tRPC, I was like:

This is it. This is what I was waiting for. This is what beginner-dev me was actually dreaming of.

I played around with tRPC and deployed a few APIs with it. I liked it. But some things still bugged me.

You still have to re-declare your schema using Zod, for example. In addition to that, similar as with GraphQL, you have to buy into a whole new ecosystem again. Can't we just stick with our proven REST APIs, and type them out?

So I came up with a new approach, an approach that actually doesn't require any external libraries but still gives you end-to-end type-safe APIs.

And don't get me wrong, tRPC is amazing, but this is just a different and less opinionated way of reaching the holy grail of e2e type-safe APIs.

The Idea

The core idea is the following: Instead of re-declaring the schema, JSON schemas are auto-generated (and it's not even needed to do that during development, so not a blocker or slow-down factor) based on the response and request types of the API controllers.

What I really love about this approach is that it has 0 dependencies, is very transparent, while offering all the upsides the evolution of tooling over the past years gave us.

It essentially consists of 3 parts:

  • A TypeScript API Controller that exposes input and output types.
  • Two utility types to extract types on the client side.
  • A TypeScript to JSON Schema Generator for type-safe runtime APIs.

And that's it. Here's a simple example, step-by-step:

Step 1: Declare your controller

import { Infrastructure } from "@local/api-utils"; import reqJsonSchema from "./GetCharacter.request.schema.json"; export type GreetingQueryParameters = { // Use JSDoc to enforce additional input data validation rules /** * @minLength 3 * @maxLength 100 * @description the full name of the user */ fullName: string; }; export type GreetingResponse = { greeting: string; } & Infrastructure.BaseControllerResponse; export class GreetingController extends Infrastructure.ControllerWithQueryParametersValidator<GreetingQueryParameters> implements Infrastructure.IController< GreetingQueryParameters, unknown, GreetingResponse > { constructor() { super({ inputSchema: reqJsonSchema as unknown as Infrastructure.TJsonSchema, }); } public async execute({ queryParameters, }: Infrastructure.ControllerExecutionParameters): Promise< Infrastructure.ControllerResponse<GreetingResponse> > { this.validateQueryParameters(queryParameters); const { fullName } = queryParameters; return { statusCode: 200, body: { greeting: `Welcome, ${fullName}`, type: "success", }, }; } }

Step 2: Implement the client query

// Get your controller type import type { GreetingController } from "../../backend-api"; import { ControllerExecutionSuccessResult, ControllerQueryParameters, } from "@local/api-utils"; export async function greetingQuery( // Fully typed input parameters, derived from the controller params: ControllerQueryParameters<GreetingController> ) { const urlParams = new URLSearchParams(params).toString(); const fetchResult = (await fetch( `http://localhost:3333/greet?${urlParams}` ).then((res) => res.json() )) as ControllerExecutionSuccessResult<GreetingController>; }

Step 3 (optional): Generate the JSON schema for runtime type safety

npm run generate-json-schemas

Thanks for reading!

I've used this approach at my startup for nearly 1.5 years now, and we are super happy with it. Let me know what you think about it.

With the rise of AI-assisted coding, approaches like these will become more and more relevant. If your AI assistant always has all the type information at their disposal, generating clean, functioning, and nice code will be a breeze. Set yourself up for success in this new phase of turbocharged application development.

You can find the GitHub repo and a link to codesandbox, where you can try it out yourself, here:

But of course, having type-safe APIs is just half the battle.

Structuring your API code in a scalable way is the other side of the coin.

The example repo contains an API and a React app, co-existing in an NX monorepository, but the concept itself is compatible with right about any backend/frontend framework/library and even JS runtime.

I will eventually create an article series about pragmatic Domain-Driven Design in TypeScript using Vertical Slice Architecture. The series will be called

You might not need a Node.js Framework (...and neither an ORM)

Keep Reading

Always bet on TS... and AI
opinion01 Mar / 2024WIP

Always bet on TS... and AI

WIP01 Mar / 2024software development

Idea dump - Things I learned (the hard way)

Idea dump - Things I learned (the hard way)