Bridgin' EffectTS and GraphQL

Hi there! I'm gonna spare you the ceremonies and dragging introductions - I'm SWE based in the NL, who admires progmatic functional programming and often finds himself in the trenches of TypeScript typesystem. In this series of posts I want to capture my journey of creating a bridge library between EffectTS and GraphQL. I might once in a while side track to discuss TypeScript as a language - how we as developers can leverage it's power - and it's going to be generic-heavy. It might be a worthy read to those who want to learn more about Type-Driven Development practices. Disclaimer: it's not meant to be a tutorial, but rather a collection of notes and thoughts on the way. The Why? I had been long time GraphQL user and as long as I've enjoyed the ecosystem and tooling built around it, I was never fully convinced by codegen approach. At first glance, one might find generating code from GraphQL schema a solid solution - you start with schema, run codegen script, get back a solid bunch of types generated for you and use them as contract that you swear to death not to break in your code. Let's have a brief look at the probably the most primitive example (using @eddeee888/gcg-typescript-resolver-files plugin): Running codegen on the following schema: type Query { user(id: ID!): User! } type User { id: ID! name: String! } ends up with query resolver file resolvers/Query/user.ts: import type { QueryResolvers } from './../../gql/types.generated'; export const user: NonNullable = async (_parent, _arg, _ctx) => { /* Implement Query.user resolver logic here */ }; alongside with schema bootstrapping files inside gql folder, hosting all the type information inferred from the original schema, so you have a well-defined contract on what exactly you need to return from the resolver Query.user toegther with what arguments resolver expects. Simple isn't it? GraphQL isn't your domain - it's convenient transport The major issue here - GraphQL schema can't serve a your domain schema - it's limited and tailored to the needs of client-server communication. As soon as your app grows large enough to a significant amount of domain logic, you'll find yourself defining domain types next to the types materialized from GraphQL schema, leading to code duplication. Crossing the boundary Application, made with TypeScript (and any other strongly typed language,) is guaranteed to be correct only within the application boundary, since compiler "owns" the code and able to give your some level of guarantee about it's correctness. However, in real world, applications talk to the outside world - databases, external services - it gets more prominent considering how well-spread modern microservice architecture is. Naive approach to solve this problem is Leap Of Faith - pretend information you get from outside your app boundary is correct and hope for the best. // rest of the code relies on `User` type, derived from GraphQL schema import { User } from '@/gql/types.generated' function getUser(id: string): Promise { const user = await fetch(`/api/users/${id}`); // We reach outside of our application boundary here return user.json() as unknown as Promise; } Experienced developers will enforce some level of correctness by adding parsing step to this implementation: import { User } from '@/gql/types.generated' import { z } from 'zod' const UserSchema = z.object({ id: z.string(), name: z.string(), }) function getUser(id: string): Promise { const user = await fetch(`/api/users/${id}`) return user.json().then(UserSchema.parse) } And you might have already started to see the problem staring at you from between the cracks - you define your schema twice, once in form of GraphQL schema and once in form of Zod schema. No doubts there is GraphQL to Zod codegen plugin, yet I couldn't get rid of the feeling it's putting cart before horse. The bottom line is - GraphQL schema is not your domain schema, and with lack of metaprogramming capabilities in TypeScript, we can't approach this problem code-first. Or can we?

Mar 26, 2025 - 14:41
 0
Bridgin' EffectTS and GraphQL

Hi there! I'm gonna spare you the ceremonies and dragging introductions - I'm SWE based in the NL, who admires progmatic functional programming and often finds himself in the trenches of TypeScript typesystem.

In this series of posts I want to capture my journey of creating a bridge library between EffectTS and GraphQL.

I might once in a while side track to discuss TypeScript as a language - how we as developers can leverage it's power - and it's going to be generic-heavy.

It might be a worthy read to those who want to learn more about Type-Driven Development practices.

Disclaimer: it's not meant to be a tutorial, but rather a collection of notes and thoughts on the way.

The Why?

I had been long time GraphQL user and as long as I've enjoyed the ecosystem and tooling built around it, I was never fully convinced by codegen approach.

At first glance, one might find generating code from GraphQL schema a solid solution - you start with schema, run codegen script, get back a solid bunch of types generated for you and use them as contract that you swear to death not to break in your code.

Let's have a brief look at the probably the most primitive example (using @eddeee888/gcg-typescript-resolver-files plugin):
Running codegen on the following schema:

type Query {
  user(id: ID!): User!
}

type User {
  id: ID!
  name: String!
}

ends up with query resolver file resolvers/Query/user.ts:

import type { QueryResolvers } from './../../gql/types.generated';
export const user: NonNullable<QueryResolvers['user']> = async (_parent, _arg, _ctx) => { /* Implement Query.user resolver logic here */ };

alongside with schema bootstrapping files inside gql folder, hosting all the type information inferred from the original schema, so you have a well-defined contract on what exactly you need to return from the resolver Query.user toegther with what arguments resolver expects. Simple isn't it?

GraphQL isn't your domain - it's convenient transport

The major issue here - GraphQL schema can't serve a your domain schema - it's limited and tailored to the needs of client-server communication. As soon as your app grows large enough to a significant amount of domain logic, you'll find yourself defining domain types next to the types materialized from GraphQL schema, leading to code duplication.

Crossing the boundary

Application, made with TypeScript (and any other strongly typed language,) is guaranteed to be correct only within the application boundary, since compiler "owns" the code and able to give your some level of guarantee about it's correctness. However, in real world, applications talk to the outside world - databases, external services - it gets more prominent considering how well-spread modern microservice architecture is.

Naive approach to solve this problem is Leap Of Faith - pretend information you get from outside your app boundary is correct and hope for the best.

// rest of the code relies on `User` type, derived from GraphQL schema
import { User } from '@/gql/types.generated'


function getUser(id: string): Promise<User> {
  const user = await fetch(`/api/users/${id}`); // We reach outside of our application boundary here
  return user.json() as unknown as Promise<User>;
}

Experienced developers will enforce some level of correctness by adding parsing step to this implementation:

import { User } from '@/gql/types.generated'
import { z } from 'zod'

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
})

function getUser(id: string): Promise<User> {
  const user = await fetch(`/api/users/${id}`)
  return user.json().then(UserSchema.parse)
}

And you might have already started to see the problem staring at you from between the cracks - you define your schema twice, once in form of GraphQL schema and once in form of Zod schema. No doubts there is GraphQL to Zod codegen plugin, yet I couldn't get rid of the feeling it's putting cart before horse.

The bottom line is - GraphQL schema is not your domain schema, and with lack of metaprogramming capabilities in TypeScript, we can't approach this problem code-first.
Or can we?