DEV Community

Cover image for ๐Ÿš€ The Black Box Principle: Decoupling API Clients with OpenAPI and TypeScript
Francisco Luna
Francisco Luna

Posted on

๐Ÿš€ The Black Box Principle: Decoupling API Clients with OpenAPI and TypeScript

When I was primarily a frontend engineer, I had a terrible relationship with APIs. If I wasn't manually rewriting types and interfaces from the backend, I was debugging runtime errors because someone changed an endpoint without telling me.

Back then I read the following advice:

'The best way to get types from the server was to get the types from it.'

What I didn't realize was this was some of the worst advice I've ever received; until I became the backend engineer responsible for maintaining both sides.

The code you're about to see isn't a hypothetical example. It's the actual technical debt that almost burned me out as a solo engineer building a startup.

I didn't understand how the backend was supposed to work in a real world environment.

So this is what I came up with:

// Are you telling me I also need the backend to JUST get some schemas? 
import { 
  SignInWithEmailOrUsernameResponseSchema,
  SignInWithEmailOrUsernameSchema 
} from "@/backend/validations/auth";

// What is this? Your own flaky OpenAPI schema?
import { AuthAPI } from "../services/auth";

export function useSignIn() {
  return useMutation<
    z.infer<typeof SignInWithEmailOrUsernameResponseSchema>,
    AxiosError<ErrorResponse>,
    z.infer<typeof SignInWithEmailOrUsernameSchema>
  >({
    mutationFn: (user) => AuthAPI.signIn(user),
    onSuccess: (data) => {
      const { accessToken } = data;
      // Only God knows how this was processed...
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

The Tragedy

You might be wondering. Francisco, what is wrong with this, really? Everything, at least when you're working with both a separate backend and frontend.

โŒ 1. Coupling

You're assuming your monolith repository will exist forever. But what happens if:

  • The backend moves to another repository?

  • You transition to microservices?

  • You need to support multiple client applications?

  • The backend team starts using languages other than TypeScript?

๐Ÿ’€ 2. Compilation and build nightmares

// Wait, why is my frontend bundle including this?
import { UserEntity } from "@/backend/entities/User";
import { PasswordHelper } from "@/backend/utils/crypto";
import { DatabaseConfig } from "@/backend/config/database";
Enter fullscreen mode Exit fullscreen mode
  • Bundle Bloat: Your frontend is now shipping backend entities, utilities, and configuration files

  • Build Complexity: Your CI/CD now needs the entire backend codebase just to build the frontend

  • Dependency Hell: Backend-specific packages leaking into frontend builds

  • Docker Disasters: Trying to untangle this mess in containerization

โš  3. Hardcoding and context switching

This was the workflow with the former setup:

Add the new API endpoint

// This needs to be extended or changed manually ๐Ÿ˜ญ
export const AUTH_API_ENDPOINTS = {
  LOGIN: `/${API_ENDPOINT_NAMES.AUTH}/login`,
  REGISTER: `/${API_ENDPOINT_NAMES.AUTH}/register`
};
Enter fullscreen mode Exit fullscreen mode

Create the new API service


// Yes, we also need backend dependencies and duplicate logic! 
const signIn = api<
  z.infer<typeof SignInRequest>,           // โ† Manual type inference
  z.infer<typeof SignInResponse>           // โ† More manual work
>({
  method: "POST",
  path: AUTH_API_ENDPOINTS.LOGIN,          // โ† Hardcoded string
  requestSchema: SignInRequest,            // โ† Duplicated validation
  responseSchema: SignInResponse,          // โ† More duplication
  type: "public",
  withCredentials: true,
});
Enter fullscreen mode Exit fullscreen mode

Create the queries and mutations

You already saw the code in the introduction, but see how duplicate things are and how prone this is to break, again.

export function useSignIn() {
  return useMutation<
    z.infer<typeof SignInWithEmailOrUsernameResponseSchema>,
    AxiosError<ErrorResponse>,
    z.infer<typeof SignInWithEmailOrUsernameSchema>
  >({
    mutationFn: (user) => AuthAPI.signIn(user),
    onSuccess: (data) => { 
      const { accessToken } = data;
      // Why are we guessing types? 
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Now pray that something does not break and repeat the same exact process for 60+ API endpoints... right?


There's a BETTER way

I performed a refactor at work where we migrated all this Zod chaos, DTOs, schema validations, and backend spaghetti into a proper API contract.

Before I walk you through the recovery, let me introduce the tool that changed everything: OpenAPI.

OpenAPI

OpenAPI is a specification format used to describe and document REST APIs in a way that can be understood by human and used by machine. Itโ€™s not just documentation, but also a contract.

Hereโ€™s what it gives you out of the box:

๐Ÿ—บ 1. Available endpoints:
Every route, method, and path is clearly defined.

๐Ÿ›  2. Query and path params for each API endpoint:
You know exactly what each endpoint expects.

โœ… 3. Response format for each status code:
You can validate what success, failure, and edge cases look like.

We'll see now how we can create a professional API specification on the server side.*

Generating OpenAPI Schemas

This process involves generating a .json or .yaml file containing your API contract.

Warning: This step varies dramatically based on:

  • The scale of your project (monolith vs microservices)

  • Your framework (Nest.js vs Spring Boot vs Go)

  • Your team's technical maturity (senior engineers vs junior team)

  • Your deployment complexity (do you need API versioning?)


The Nest.js Approach: Decorator-Driven

In Nest.js you use decorators to automatically generate your OpenAPI spec:

// task.controller.ts
@Controller('tasks')
export class TasksController {
  @ApiTags('Tasks')
  @Get()
  @ApiOkResponse({ 
    type: TaskEntity, 
    isArray: true,
    description: 'Returns all tasks for the authenticated user' 
  })
  @ApiUnauthorizedResponse({ description: 'Invalid or missing token' })
  findAll() {
    return this.tasksService.findAll();
  }
}
Enter fullscreen mode Exit fullscreen mode
// task.entity.ts
export class TaskEntity {
  @ApiProperty({ 
    type: String,
    description: 'Unique task identifier',
    example: '550e8400-e29b-41d4-a716-446655440000'
  })
  id: string;

  @ApiProperty({
    type: String,
    description: 'Task title',
    example: 'Finish API documentation',
    maxLength: 255
  })
  title: string;

  @ApiProperty({
    type: Date,
    description: 'When the task was created',
    example: '2024-01-15T10:30:00.000Z'
  })
  createdAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

The Reality Check

If look at Reddit or technical blogs/forums, some developers prefer writing OpenAPI specs manually. The code-first approach has trade-offs:

โœ… Pros:

  • Single source of truth (your code generates docs)
  • Harder for docs to drift from implementation
  • Faster iteration in early-stage startups

โŒ Cons:

  • Can get messy with complex APIs
  • Limited control over exact OpenAPI features
  • Decorator pollution in your business logic

The Framework for Success

  • Mirror your domain entities and find a DRY way to manage them
  • Document controllers methodically. Descriptions and examples are important too
  • Display with SwaggerUI or Scalar for team collaboration
  • Generate client SDKs automatically as part of your CI/CD

Choosing Your API Visualizer

Honestly? Whatever. The important thing is that you have a visual interface for your API. Choose the one that's the most suitable for your team needs. Let's explore the common options with more detail:

Swagger UI - The battle-tested veteran

  • โœ… Universally recognized, extensive customization
  • โŒ Can feel dated, heavier bundle

Scalar - The modern contender

  • โœ… Beautiful UI, faster, better DX
  • โœ… Embracing by major frameworks (.NET's new default)
  • โŒ Less ecosystem maturity

Swagger UI documentation

Scalar documentation

โญ Bonus: Frontend Implementation

Now you might be wondering, how does this actually work on the frontend?

Well, itโ€™s not difficult.

Let me introduce you to OpenAPI TypeScript

Imagine if your axios or fetch client had complete type safety and could interact with your API without manual guesswork. Thatโ€™s exactly the problem OpenAPI TypeScript solves. All you need is the OpenAPI specification, which can be from your backend or a remote URL.

๐Ÿš€ Getting Started with OpenAPI TypeScript

  1. Install the tool
npm install -D openapi-typescript
Enter fullscreen mode Exit fullscreen mode
  1. Generate your types

From a local schema:

npx openapi-typescript ./path/to/my/schema.yaml -o ./path/to/my/schema.d.ts
# ๐Ÿš€ ./path/to/my/schema.yaml -> ./path/to/my/schema.d.ts [7ms]
Enter fullscreen mode Exit fullscreen mode

From a remote schema:

npx openapi-typescript https://myapi.dev/api/v1/openapi.yaml -o ./path/to/my/schema.d.ts
# ๐Ÿš€ https://myapi.dev/api/v1/openapi.yaml -> ./path/to/my/schema.d.ts [250ms]
Enter fullscreen mode Exit fullscreen mode

Now you can import types and contracts directly from your schema:

import type { paths, components } from "./my-openapi-3-schema";

// Schema object
type MyType = components["schemas"]["MyType"];

// Path parameters
type EndpointParams = paths["/my/endpoint"]["parameters"];

// Response objects
type SuccessResponse =
  paths["/my/endpoint"]["get"]["responses"][200]["content"]["application/json"]["schema"];

type ErrorResponse =
  paths["/my/endpoint"]["get"]["responses"][500]["content"]["application/json"]["schema"];
Enter fullscreen mode Exit fullscreen mode

Wait, what about type-safe API clients?

Even with generated types, youโ€™d still need to manually wire up your API calls!

Thatโ€™s where OpenAPI Fetch comes in.

It gives you a fully type-safe client. No more guessing and no more duplicated logic.

Example: Type-Safe API Calls

import createClient from "openapi-fetch";
import type { paths } from "./my-openapi-3-schema"; // generated by openapi-typescript

const client = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });

const {
  data, // only present if 2XX response
  error, // only present if 4XX or 5XX response
} = await client.GET("/blogposts/{post_id}", {
  params: {
    path: { post_id: "123" },
  },
});

await client.PUT("/blogposts", {
  body: {
    title: "My New Post",
  },
});
Enter fullscreen mode Exit fullscreen mode

This can also be implemented seamlessly with React Query, SWR or you favorite data fetching/mutation library.


Conclusion: The Black-Box

Please, always remember this:

Your frontend application must treat the backend as a black box.

It does not care about your database schema, crypto helpers, or specific implementation details. It only sees the API and the public interfaces.

By forcing your entire system to rely on a generated, language-agnostic OpenAPI Contract, you achieve true separation. You replace the questionable monorepo setup with an actual handshake.

This is the difference between a codebase that scales and a codebase that burns you out.

Stop debugging what you didn't write and start building what you control.


๐Ÿš€ Join Escaping From the Abyss

Thank you so much for reading! :)

Iโ€™m launching a newsletter where I share hard-earned lessons from the trenches of startup engineering, especially in backend development and product planning.

Iโ€™ve seen engineers suffer from broken workflows and scope creep. Iโ€™ve even lived it. And I believe every dev deserves clarity, autonomy, and the tools to build well; both technically and emotionally.

This isnโ€™t just about clean code. Itโ€™s about clean contracts, clean boundaries, and clean teams.

Letโ€™s make startups a better place, through better engineering, better business practices, and better mentorship.

Join me here! Letโ€™s escape from the abyss.

Top comments (0)