← All articles
LANGUAGES Code Generation Tools: When Codegen Helps and When I... 2026-02-09 · 8 min read · codegen · openapi · protobuf

Code Generation Tools: When Codegen Helps and When It Creates Tech Debt

Languages 2026-02-09 · 8 min read codegen openapi protobuf grpc graphql prisma scaffolding

Code Generation Tools: When Codegen Helps and When It Creates Tech Debt

Code generation sits on a spectrum. On one end, you have tools that generate types from a schema -- strictly additive, easy to regenerate, hard to screw up. On the other end, you have scaffolding tools that generate entire application structures -- helpful for getting started, but the generated code becomes yours to maintain forever.

Understanding where a tool falls on this spectrum is the key to using codegen effectively. A protobuf-generated client is disposable infrastructure. A scaffolded Next.js app with custom business logic is not. This guide covers the major code generation tools, when they actually help, and when they are quietly creating maintenance problems.

Schema-Driven Code Generation

The best codegen tools work from a single source of truth -- a schema file -- and generate code that you never edit by hand. If the schema changes, you regenerate. If the generated code has a bug, you fix the generator or the schema, not the output.

OpenAPI / Swagger Code Generation

OpenAPI (formerly Swagger) specs describe REST APIs. Codegen tools read these specs and produce typed clients, server stubs, or documentation.

openapi-typescript -- the best option for TypeScript projects:

npm install -D openapi-typescript openapi-fetch
# Generate types from an OpenAPI spec
npx openapi-typescript https://api.example.com/openapi.json -o src/api/types.ts

# Or from a local file
npx openapi-typescript specs/api.yaml -o src/api/types.ts

The generated types are used with openapi-fetch for a fully typed API client:

// src/api/client.ts
import createClient from 'openapi-fetch';
import type { paths } from './types';

const client = createClient<paths>({
  baseUrl: 'https://api.example.com',
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

// Fully typed -- params, request body, and response
const { data, error } = await client.GET('/users/{id}', {
  params: { path: { id: '123' } },
});

// TypeScript knows data.name exists (or whatever the schema says)
console.log(data?.name);

openapi-generator -- the kitchen-sink option:

# Install via npm
npm install @openapitools/openapi-generator-cli -g

# Generate a TypeScript Axios client
openapi-generator-cli generate \
  -i specs/api.yaml \
  -g typescript-axios \
  -o src/generated/api

# Generate a Python FastAPI server stub
openapi-generator-cli generate \
  -i specs/api.yaml \
  -g python-fastapi \
  -o server/generated

openapi-generator supports 50+ language/framework combinations. The generated code is often verbose and opinionated about HTTP libraries. For TypeScript, openapi-typescript + openapi-fetch is cleaner. For other languages, openapi-generator is usually the only game in town.

Protobuf / gRPC Code Generation

Protocol Buffers define message formats and service interfaces. The protoc compiler generates serialization code, and gRPC plugins generate client/server stubs.

// protos/user.proto
syntax = "proto3";

package user.v1;

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
  rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
}

message GetUserRequest {
  string id = 1;
}

message GetUserResponse {
  User user = 1;
}

message User {
  string id = 1;
  string name = 2;
  string email = 3;
  int64 created_at = 4;
}

For TypeScript (using ts-proto):

npm install -D ts-proto

protoc --plugin=protoc-gen-ts_proto=./node_modules/.bin/protoc-gen-ts_proto \
  --ts_proto_out=src/generated \
  --ts_proto_opt=outputServices=grpc-js \
  --ts_proto_opt=esModuleInterop=true \
  protos/user.proto

For Go:

protoc --go_out=. --go-grpc_out=. protos/user.proto

Buf -- the modern protobuf toolchain:

Buf replaces raw protoc invocations with a managed workflow. It handles linting, breaking change detection, and code generation.

# buf.gen.yaml
version: v2
plugins:
  - remote: buf.build/protocolbuffers/go
    out: gen/go
    opt: paths=source_relative
  - remote: buf.build/grpc/go
    out: gen/go
    opt: paths=source_relative
  - remote: buf.build/community/timostamm-protobuf-ts
    out: gen/ts
buf generate
buf lint
buf breaking --against '.git#branch=main'

Buf's breaking change detection is genuinely useful -- it catches accidental field renumbering, removed fields, and type changes before they ship.

GraphQL Code Generation

GraphQL's type system makes it a natural fit for codegen. The dominant tool is graphql-codegen.

npm install -D @graphql-codegen/cli @graphql-codegen/typescript \
  @graphql-codegen/typescript-operations \
  @graphql-codegen/typescript-react-query
// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: 'http://localhost:4000/graphql',
  documents: 'src/**/*.graphql',
  generates: {
    'src/generated/graphql.ts': {
      plugins: [
        'typescript',
        'typescript-operations',
        'typescript-react-query',
      ],
      config: {
        fetcher: {
          endpoint: 'process.env.GRAPHQL_ENDPOINT',
          fetchParams: {
            headers: {
              'Content-Type': 'application/json',
            },
          },
        },
        exposeQueryKeys: true,
        exposeFetcher: true,
      },
    },
  },
};

export default config;

Write your queries in .graphql files and get fully typed hooks:

# src/queries/users.graphql
query GetUser($id: ID!) {
  user(id: $id) {
    id
    name
    email
    posts {
      id
      title
    }
  }
}

mutation CreateUser($input: CreateUserInput!) {
  createUser(input: $input) {
    id
    name
  }
}
// Generated hook usage
import { useGetUserQuery, useCreateUserMutation } from './generated/graphql';

function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading } = useGetUserQuery({ id: userId });

  // data.user is fully typed -- name, email, posts all autocomplete
  return <div>{data?.user?.name}</div>;
}

Run codegen in watch mode during development:

npx graphql-codegen --watch

ORM and Database Code Generation

Prisma

Prisma generates a fully typed database client from a schema file. It is the most popular ORM in the TypeScript ecosystem and the codegen is what makes it compelling.

// prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
}

model Post {
  id        String   @id @default(cuid())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  String
  createdAt DateTime @default(now())
}
npx prisma generate  # Generates the typed client
npx prisma db push   # Syncs schema to database
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// Every query is fully typed, including relations
const userWithPosts = await prisma.user.findUnique({
  where: { email: '[email protected]' },
  include: { posts: { where: { published: true } } },
});

// TypeScript knows userWithPosts.posts exists and is Post[]

Prisma's codegen is excellent -- possibly the best developer experience for database access in TypeScript. The downside is that the generated client is heavy (~2MB) and the query engine is a Rust binary. For serverless environments, cold start times can be noticeable.

Drizzle (Alternative Approach)

Drizzle takes the opposite approach -- you define your schema in TypeScript, and the types flow naturally without a generation step:

// schema.ts
import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: text('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name'),
});

export const posts = pgTable('posts', {
  id: text('id').primaryKey(),
  title: text('title').notNull(),
  published: boolean('published').default(false),
  authorId: text('author_id').references(() => users.id),
});

No generation step, no binary engine. The types are inferred at compile time. For projects where Prisma's overhead is a concern, Drizzle is worth considering.

Scaffolding and Template Tools

Scaffolding tools generate application code from templates. Unlike schema-driven codegen, the output is meant to be edited. This is where codegen starts creating tech debt if you are not careful.

create-t3-app

The T3 stack scaffolder generates a Next.js app with tRPC, Prisma, Tailwind, and NextAuth -- all pre-wired.

npm create t3-app@latest my-app

It asks you which pieces you want and generates a working app with proper TypeScript configuration. The generated code is well-structured, but it is your code now. When T3 conventions change (and they do -- the project has shifted from src/pages to src/app to reflect Next.js changes), you are on your own to update.

Verdict: Good for new projects that match the T3 stack exactly. Not useful for adding to existing projects.

Hygen

Hygen generates files from EJS templates. It is designed for creating new components, modules, or features within an existing project.

npm install -D hygen
npx hygen init self

Create a template:

_templates/
  component/
    new/
      component.ejs.t
      test.ejs.t
      styles.ejs.t
      index.ejs.t
---
to: src/components/<%= name %>/<%= name %>.tsx
---
import styles from './<%= name %>.module.css';

interface <%= name %>Props {
  children: React.ReactNode;
}

export function <%= name %>({ children }: <%= name %>Props) {
  return (
    <div className={styles.root}>
      {children}
    </div>
  );
}
npx hygen component new --name Button
# Creates:
#   src/components/Button/Button.tsx
#   src/components/Button/Button.test.tsx
#   src/components/Button/Button.module.css
#   src/components/Button/index.ts

Hygen is lightweight and effective. The templates live in your repo, so they evolve with your codebase. The risk is low because each generated file is small and self-contained.

Plop

Plop is similar to Hygen but uses Handlebars templates and a JavaScript-based configuration.

// plopfile.js
export default function (plop) {
  plop.setGenerator('component', {
    description: 'React component',
    prompts: [
      {
        type: 'input',
        name: 'name',
        message: 'Component name?',
      },
      {
        type: 'confirm',
        name: 'withTest',
        message: 'Include test file?',
        default: true,
      },
    ],
    actions: (data) => {
      const actions = [
        {
          type: 'add',
          path: 'src/components/{{pascalCase name}}/{{pascalCase name}}.tsx',
          templateFile: 'plop-templates/component.tsx.hbs',
        },
        {
          type: 'add',
          path: 'src/components/{{pascalCase name}}/index.ts',
          templateFile: 'plop-templates/index.ts.hbs',
        },
      ];

      if (data.withTest) {
        actions.push({
          type: 'add',
          path: 'src/components/{{pascalCase name}}/{{pascalCase name}}.test.tsx',
          templateFile: 'plop-templates/component.test.tsx.hbs',
        });
      }

      return actions;
    },
  });
}
npx plop component
# Interactive prompts, then generates files

Plop is more flexible than Hygen (conditional logic, multiple prompts, custom actions), but also heavier. For simple file generation, Hygen is simpler. For complex workflows with branching logic, Plop is better.

When Codegen Helps vs Creates Tech Debt

Codegen Helps When:

  1. The output is regenerable. If you can delete the generated code and regenerate it from the schema, you are in good shape. OpenAPI types, Prisma client, protobuf stubs -- all regenerable.

  2. The schema is the source of truth. If your API spec, protobuf definition, or database schema is what changes first, and the code follows, codegen keeps things in sync.

  3. The generated code is not edited. The moment someone hand-edits a generated file, you have forked from the generator. Future regeneration will overwrite their changes.

  4. It eliminates boilerplate. If you are writing the same 50 lines of type definitions every time you add an API endpoint, codegen is a net win.

Codegen Creates Debt When:

  1. The output becomes "your code." Scaffolded apps, starter templates, ejected configurations -- these are codegen that runs once and then you own the result.

  2. The generator is abandoned. If the tool that generated your 10,000-line client library stops being maintained, you now maintain that code.

  3. Generated code is committed and edited. Some teams commit generated code and then make manual fixes. This makes future regeneration dangerous or impossible.

  4. The abstraction does not match your needs. If you spend more time fighting the generated code than you save by not writing it manually, the codegen is hurting you.

Rules of Thumb

// package.json -- regeneration script
{
  "scripts": {
    "generate": "npm run generate:api && npm run generate:graphql && npm run generate:prisma",
    "generate:api": "openapi-typescript specs/api.yaml -o src/generated/api-types.ts",
    "generate:graphql": "graphql-codegen",
    "generate:prisma": "prisma generate",
    "prebuild": "npm run generate",
    "ci:check-generated": "npm run generate && git diff --exit-code src/generated/"
  }
}

Bottom Line

For REST APIs: Use openapi-typescript + openapi-fetch. It generates lightweight types, the client is minimal, and the ergonomics are excellent.

For gRPC: Use Buf instead of raw protoc. The breaking change detection alone is worth it, and the codegen configuration is cleaner.

For GraphQL: graphql-codegen is the standard. It is mature, well-maintained, and the React Query/urql plugins produce genuinely useful hooks.

For databases: Prisma if you want the best DX and can tolerate the binary engine. Drizzle if you want lightweight types without a generation step.

For scaffolding: Hygen for simple file generation. Plop for interactive scaffolding with complex logic. Use create-t3-app or similar starters only for brand-new projects that match the template exactly.

The golden rule: Generated code should be regenerable. If you need to edit the output, the tool is not a good fit for your use case. Switch to a lighter-weight approach or write the code by hand.