Comparison

Comparison with other tools

GraphQL

GraphQL is one of the best tools for creating type-safe APIs. It offers much more than only type-safe API, but ptsq aims only for type-safety.

ptsq is more for smaller projects, unlike GraphQL which is for large to enormous projects, however setting ptsq to be type-safe is much easier than GraphQL where you need to know how to run other tools to create a type-safe API as well as a type-safe backend and client.

ptsq backend must be written in TS, GraphQL supports the possibility of creating a backend in almost any programming language.

Tools for creating type-safe backend code and for generating the GraphQL schema such as Nexus (opens in a new tab) or Pothos (opens in a new tab) are very good, but they both require a build step in creating a schema, ptsq does not require any build step for creating a schema of the API.

As you can see below, the GraphQL resolver requires to define a schema of input arguments, but the schema does not have any validation, just a type validation.

import { stringArg } from 'nexus';
 
queryField('user', {
  type: 'User',
  args: {
    id: stringArg(),
  },
  resolve: () => {
    // ...
  },
});

Validation must be done inside a resolve function or with some plugin that allows to write validation schema in the resolver.

import { stringArg } from 'nexus';
import { z } from 'zod';
 
queryField('user', {
  type: 'User',
  args: {
    id: stringArg(),
  },
  validationSchema: z.object({
    id: z.string(),
  }),
  resolve: () => {
    // ...
  },
});

As you can see, we duplicated the input arguments schema in the validation Schema. But GraphQL requires us to define the schema of args, so we cannot infer the GraphQL schema from the validation schema, because GraphQL schema types can be complex and must have a name, such as type: User that we returning from the resolver.

Another issue is that the GraphQL validation is really slow, cause it has to validate every argument against its GraphQL schema, and then validate against the validation schema.

If there are many arguments, the validation step can last a long time. ptsq does not have its own schema, it has only one validation Zod schema, that validates the whole pool of arguments.

import { stringArg } from 'nexus';
import { z } from 'zod';
 
queryField('user', {
  type: 'User',
  validationSchema: z.object({
    id: z.string(),
  }),
  resolve: () => {
    // ...
  },
});

There is no output validation, which means that only types must match, but the runtime types can be different.

resolver
  .args(Type.Object({ name: Type.String() }))
  .output(UserSchema)
  .query(() => {
    // ...
  });

In ptsq you don't duplicate the input and output schemas, the open API schema for introspection is inferred from validation schemas that are defined for input and output.

That means both, input and output, are validated on the type level and in runtime.

You don't have to set up some other tools such as Nexus or Pothos for generating a schema and writing type-safe resolvers, because ptsq offers this in the library directly.

tRPC

tRPC is also a tool for creating type-safe API, but it does not allow to create a type-safe open API. That means the type-safety is only proprietary.

There are plugins for creating REST API by tRPC resolvers, but the REST API router loses the type-safety.

It's designed for monorepos, as you need to export/import the type definitions from/to your server. The ptsq thanks to schema introspection can be easily used in multirepos.

tRPC is designed for full-stack Typescript setup, which means both, server and client must be written in Typescript for type safety, in ptsq only the server must be written in Typescript.

tRPC loses type-safety with transformers such as the Superjson transformer because then it does not respect serializable types.

In the example below it returns a Date object and the transformer transforms it to something serializable, on the client the same transformer transforms it back to the Date object.

publicProcedure.output(z.Date()).query(() => {
  return new Date();
});

The way that tRPC can lie to you in the incoming type from the server or the input type to the procedure is described below.

class MyClass {
  constructor(
    public x: number,
    public y: number,
  ) {}
 
  calculateDiff() {
    return this.y - this.x;
  }
}
 
publicProcedure.query(() => {
  return new MyClass(10, 20);
});

On the client it tells you that the incoming type is MyClass, but that is not true, incoming type will be { x: number; y: number }, because classes are not serializable.

ptsq has another solution for this problem and that is arguments transformations.

ptsq does not allow you to use any non-serializable type for resolver input and output.

Another way that tRPC can lie to you is in complex schema chaining.

export const test = publicProcedure
  .input(z.object({ obj: z.object({ string: z.string(), num1: z.number() }) }))
  .input(z.object({ obj: z.object({ string: z.string(), num2: z.number() }) }))
  .use(({ input, next }) => {
    console.log(input);
    return next();
  })
  .input(z.object({ obj: z.object({ string: z.string(), num3: z.number() }) }))
  .query(({ input }) => input);

This is complex input chaining, but tRPC lies in the middleware on the server, and the type of the input is

{
  obj: { string: string; num1: number; }
} & {
  obj: { string: string; num2: number; }
}

but that is not true. The input will be only

{
  obj: {
    string: string;
    num2: number;
  }
}

because the Zod validation schema strips the additional keys of the input object.

In same way, it will lie on the client side, that the incoming response type will be

{
  obj: { string: string; num1: number; }
} & {
  obj: { string: string; num2: number; }
} & {
  obj: { string: string; num3: number; }
}

that is also not true, the type that the server will send is only

{
  obj: {
    string: string;
    num3: number;
  }
}

Summary

So as you can see, both tools, GraphQL and tRPC have some downsides to creating type-safe API.

GraphQL in its complexity, slow validation and code duplication and tRPC in lying types, strict project structure and no support for type-safe open API.

ptsq tries to combine the best from those two tools for creating type-safe open API.