Software Dissection

About

Breaking Down tRPC: Build a Minimal Version from Scratch Part 2 (Types)

Cover Image for Breaking Down tRPC: Build a Minimal Version from Scratch Part 2 (Types)
Glody Mbutwile
Glody Mbutwile

Github Repo

This is the second part of the series on how to build a minimal version of tRPC from scratch. In the First part, we built an RPC server in JavaScript and generated the client.

Adding Types to the Procedure

class Procedure {
    type = ''
    handler = () => {}
    validator = null

    input(validator) { 
        this.validator = validator
        return this
    }

    query(handler) {
        this.type = 'query'
        this.handler = handler
        return this
    }

    mutation(handler) {
        this.type = 'mutation'
        this.handler = handler
        return this
    }
}

To achieve this, we need parameter and return types for inputs, queries, and mutations. Let’s start with the input.

class Procedure {
    validator = null

    input(validator) { 
        this.validator = validator
        return this
    }
}

Since we don’t know the type of the input, we need to use a generic type to represent it. This generic type will extend the Zod schema.

It will return a Procedure class that infers the input type.

class Procedure<InputType = undefined> {
    validator?: z.Schema<any>

    constructor() {
        this.validator = undefined
    }

    input<VType extends z.Schema<any>>(validator?: VType) {
        this.validator = validator
        return this as Procedure<HandlerType, Type, z.infer<VType>>
    }
}

We can apply the same approach for the query type and mutation type. For handlers, we will infer the input type from the Procedure class’s generic input type.

For queries, we’ll set the type to 'query', and for mutations, we’ll set it to 'mutation'.

import { z } from "zod";


export class Procedure<HandlerType, Type, InputType = undefined> {
    type?: unknown
    handler: unknown = async () => {}
    validator?: z.Schema<any>

    constructor() {
        this.validator = undefined
    }

    input<VType extends z.Schema<any>>(validator?: VType) {
        this.validator = validator
        return this as Procedure<HandlerType, Type, z.infer<VType>>
    }

    query<HType extends HandlerType>(handler: ({ input }:{ input: InputType }) => Promise<HType>) {
        this.type = 'query'
        this.handler = handler
        return this as Procedure<HType, 'query', InputType>
    }

    mutation<HType extends HandlerType>(handler: ({ input }:{ input:InputType }) => Promise<HType>) {
        this.type = 'mutation'
        this.handler = handler
        return this as Procedure<HType, 'mutation', InputType>
    }
}

Adding Type Safety to createRouter and createRPC

const createRouter = (procedureMap) => {
    const router = {}
    Object.entries(procedureMap).forEach(([key, value]) => {
        router[key] = {
            type: value.type,
            validator: value.validator,
            handler: value.handler
        }
    })
    return router
}

const createRPC = () => {
    return {
        procedure: () => new Procedure(),
        router: (procedureMap) => createRouter(procedureMap)
    }
}

The most important part here is the type of the procedureMap. It will be a Record where the keys are not simple strings but instead are the keys of the provided procedureMap.

Using keyof, we can retrieve the keys of the passed-in procedureMap. The values will be Procedure objects with unknown handlers, types, and inputs.

const createRouter = <RType extends Record<keyof RType, Procedure<unknown, unknown, unknown>>>(
    procedureMap: RType
) => {
    return procedureMap
}

export const createRPC = <Rtype extends Record<keyof Rtype, Procedure<unknown, unknown, unknown>>>(
    
) => {
    return {
        procedure: () => new Procedure<unknown, unknown, unknown>(),
        router: <Router extends Rtype>(procedureMap: Router) => createRouter<Router>(procedureMap)
    }
}

export type RouterType = ReturnType<typeof createRouter>

Adding type safety to handlers

This is straightforward. We’ll add a mutation handler to proxy the mutation.

import { RouterType, Procedure } from "../../backend/rpc"

type DefinitionType = {
    name: string,
    type: string,
    input: any
}

const fetchRPC = async (url: string, definition: DefinitionType) => {
    return fetch(url, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            procedure: definition.name,
            type: definition.type,
            body: definition.input
        })
    }).then(res => res.json())
}

const queryHandler = {
    get: function(target: RPCClient, prop: string) {
        return function(...args: any[]) {
            return fetchRPC(target.url, {
                "name": prop,
                "type": "query",
                "input": args[0]
            })
        }
    }
}

const mutationHandler = {
    get: function(target: RPCClient, prop: string) {
        return function(...args: any[]) {
            return fetchRPC(target.url, {
                "name": prop,
                "type": "mutation",
                "input": args[0]
            })
        }
    }
}

class RPCClient {
    url = ''
    constructor(url: string) {
        this.url = url
    }
}

Adding type safety to the client

This part has been the trickiest.

When we create a router on the server and export it, for example:

export const router = rpc.router({
    getShinobi: rpc.procedure().query(async () => {
        return {
            "id": 1,
            "result": "Shinobi!"
        }
    }),
    getSorcerer: rpc.procedure().query(async () => {
        return {
            "id": 1,
            "result": "Sorcerer!"
        }
    }),
    addShinobi: rpc
        .procedure()
        .input(z.object({ name: z.string() }))
        .mutation(async ({ input }) => {
            return {
                "data": input.name
            }
        })
})

export type AppRouter = typeof router

On the client side, we want to use it like this:

type AppRouter = {
    getShinobi: Procedure<{
        id: number;
        result: string;
    }, "query", void>;
    getSorcerer: Procedure<{
        id: number;
        result: string;
    }, "query", void>;
    addShinobi: Procedure<{
        data: string;
    }, "mutation", {
        ...;
    }>;
}

client side we want to use it like this:

client.api.query.getShinobi()

// or 

client.api.mutate.addShinobi({ name })

To achieve this, we need to differentiate between queries and mutations. We can do this by filtering based on type using conditional types.

Using Conditional Types

Conditional types are a feature of TypeScript that allow the type of a variable to be determined based on a condition. Using the extends keyword, we can filter types by the type of the variable.

Let’s create a type that filters the RouterType by the type of the Procedure.

type FilterProceduresByType<T, QueryType extends "query" | "mutation"> = {
    [K in keyof T]: T[K] extends Procedure<any, infer Type, any> 
      ? Type extends QueryType
        ? T[K] 
        : never 
      : never
}

We will iterate as follows: For each key K in keyof T, if T[K] is a Procedure and the Procedure type is QueryType, return T[K]. Otherwise, return never.

Afterward, we will remove the never type from the object. For each key K in keyof T, if T[K] is never, the key is excluded; otherwise, it is included.

type RemoveNever<T> = {
    [K in keyof T as T[K] extends never ? never : K]: T[K]
};

By combining these steps, we can filter out all procedures that are not of type QueryType, leaving only those that match.

type RemoveNever<T> = {
    [K in keyof T as T[K] extends never ? never : K]: T[K]
};

type FilterProceduresByType<T, QueryType extends "query" | "mutation"> = RemoveNever<{
    [K in keyof T]: T[K] extends Procedure<any, infer Type, any> 
      ? Type extends QueryType
        ? T[K] 
        : never 
      : never
}>

Implementing the createClient Function

For the createClient function, we will use the RouterType as the generic type and proxy the client with both the queryHandler and mutationHandler.

const createClient = <Rtype extends RouterType>(url: string) => {
    return {
        api: {
            query: new Proxy(new RPCClient(url), queryHandler),
            mutate: new Proxy(new RPCClient(url), mutationHandler)
        }
    } as unknown as {
        api: {
            query: {
                // type for query
            }, 
            mutate: {
               // type for mutation
            }, 
        }
    }
}

This is where the type safety magic happens.

We will derive the types for queries and do the same for mutations.

  • Keys for Queries: These will be keys from FilterProceduresByType with the type query.

  • Value for Queries: A function that takes a conditional input type. If the RouterType[T] value extends Procedure, the input type will be the inferred input type. Otherwise, it will be void.

  • Return Type: A Promise of the handler type. If the RouterType[T] value extends Procedure, the handler type will be the inferred handler type. Otherwise, it will be never.

query: {
    [T in keyof FilterProceduresByType<Rtype, 'query'>]:
    (input: Rtype[T] extends Procedure<any, any, infer InputType> ? InputType : void ) => 
        Promise<Rtype[T] extends Procedure<infer HandlerType, any, any> ? HandlerType : never>
}, 

This setup gives us all procedures that are of the query type, along with their names, input types, and return types.

We will use the same approach for mutations and combine both.

const createClient = <Rtype extends RouterType>(url: string) => {
    return {
        api: {
            query: new Proxy(new RPCClient(url), queryHandler),
            mutate: new Proxy(new RPCClient(url), mutationHandler)
        }
    } as unknown as {
        api: {
            query: {
                [T in keyof FilterProceduresByType<Rtype, 'query'>]:
                (input: Rtype[T] extends Procedure<any, any, infer InputType> ? InputType : void ) => 
                    Promise<Rtype[T] extends Procedure<infer HandlerType, any, any> ? HandlerType : never>
            }, 
            mutate: {
                [T in keyof FilterProceduresByType<Rtype, 'mutation'>]:
                (input: Rtype[T] extends Procedure<any, any, infer InputType> ? InputType : void ) => 
                    Promise<Rtype[T] extends Procedure<infer HandlerType, any, any> ? HandlerType : never>
            }, 
        }
    }
}

export default {
    createClient
}

Conclusion

With this setup, we achieve end-to-end type safety between the client and server. If we update the procedure or router types, the client types will update automatically.

That’s why I like tRPC so much—it’s type-safe and easy to use.

Thanks for reading! I hope you enjoyed this article.

See the Github Repo for the code.

Follow me or say hello on Twitter and Linkedin