Right, let’s settle the big architectural debate you’re about to have with yourself or your team: do you define your GraphQL schema first and then write the code to match it (Schema-First), or do you write your code in TypeScript and let a library generate the schema from it (Code-First)?

Both approaches get you to the same destination—a glorious, type-safe GraphQL API—but the journey is wildly different. Neither is “wrong,” but one will likely feel more natural to you, and choosing poorly can lead to some serious grumpiness down the line.

The Schema-First Purist’s Path

This is the way it was originally intended. You, the architect, sit down and meticulously craft a .graphql schema file. You define your Query type, your Mutation type, your precious User object. This file is the single source of truth, the contract between your API and the outside world.

Then, you hand this contract to your backend developers and say, “Make it work.” They write resolvers that must conform to this structure. The beauty here is the clarity and separation of concerns. The schema is a design document, first and foremost. It’s also fantastic for frontend developers; they can start mocking queries against the known schema long before a single backend line is written.

The tooling for this in TypeScript is rock solid. You use graphql-code-generator to eat your schema and your GraphQL operations, and it vomits out pristine TypeScript types and resolver interfaces. It’s glorious.

// schema.graphql
type User {
  id: ID!
  name: String!
  email: String!
}

type Query {
  getUser(id: ID!): User
}
# Generate types! Run this in your package.json scripts.
graphql-codegen --config codegen.ts
// generated/types.ts
export type Maybe<T> = T | null;
export type User = {
  __typename?: 'User';
  id: string;
  name: string;
  email: string;
};

export type QueryGetUserArgs = {
  id: string;
};

Now, your resolver has to implement this generated interface. The type safety is absolute.

// resolvers/user.ts
import { QueryResolvers } from '../generated/types';

const getUser: QueryResolvers['getUser'] = async (_, { id }) => {
  // `id` is typed as string. The return value MUST be a `User` or `null`.
  const user = await db.user.findUnique({ where: { id } });
  // This would be a type error: return { name: user.name };
  return user; // This works because our DB model aligns with the GQL type.
};

The pitfall? You now have two sources of truth for your types: your GraphQL schema and your database models (e.g., Prisma). Keeping them in sync can feel like busywork. You change a field in the database, you must change it in the schema, then regenerate your types. It’s a process.

The Code-First Maverick’s Gambit

This approach asks a simple, rebellious question: “I’m already writing TypeScript, why am I also writing GraphQL SDL?” Enter libraries like TypeGraphQL and Pothos. You define your types as classes right in your TypeScript code, decorators and all. The library then introspects your code and generates the GraphQL schema for you on the fly.

This is where you get true “single source of truth” for your domain models. Your TypeScript class is your GraphQL type. Change a field here, and it changes everywhere instantly. No codegen step. It feels incredibly DRY.

Let’s look at Pothos, because its builder pattern is less magical (and frankly, more TypeScript-friendly) than decorators.

// user.ts - This is your one and only User definition.
import { schema } from './pothos-builder';

builder.objectType('User', {
  description: 'A system user', // See? Docs live right next to the code.
  fields: (t) => ({
    id: t.exposeID('id', { description: 'The unique user ID' }),
    name: t.exposeString('name'),
    email: t.exposeString('email'),
    // Computed field that's not in the DB? Easy.
    avatarUrl: t.string({
      resolve: (user) => `https://avatar.com/${user.id}`,
    }),
  }),
});

// Define your query
builder.queryField('getUser', (t) =>
  t.field({
    type: 'User',
    nullable: true,
    args: {
      id: t.arg.id({ required: true }),
    },
    resolve: async (_, { id }) => {
      // `id` is a string. The return value must match the 'User' type we defined.
      return await db.user.findUnique({ where: { id } });
    },
  })
);

The resolver and the type definition are colocated. The benefit is immense agility. The downside? Your schema is now an emergent property of your codebase, not a designed contract. It can be harder to reason about the big picture. Also, good luck getting your frontend team to wait for the backend to be running just to see the schema. You’ll need to run a script to generate the .graphql file from your built code for them, which… kinda defeats the whole “no codegen” point, doesn’t it?

So, Which One is Actually Better?

Choose Schema-First if:

  • You value API design as a first-class, collaborative exercise.
  • You want frontend and backend to work in parallel from a stable contract.
  • You don’t mind the extra codegen step and maintaining two type systems.

Choose Code-First (Pothos/TypeGraphQL) if:

  • You are building the API yourself and value development speed.
  • The idea of a single source of truth for your types in TypeScript makes you weep with joy.
  • You hate the context switching between .graphql files and .ts files.

My brutally honest opinion? For greenfield projects where I have full control, I reach for Pothos. The developer experience is just too productive to ignore. But on a larger team with explicit API governance, Schema-First is the undisciplined-but-brilliant programmer’s necessary straitjacket. It keeps everyone honest.