Building Reliable APIs in Typed Systems: Enforcing Contracts with OpenAPI


As an introduction (or refresher), application programming interface (API) refers to a set of specifications or rules by which computing systems communicate and request services from each other. Think of a TV remote control: when you press the volume up button, you're making a specific request that the TV understands and responds to by increasing the volume. Reliability in this context refers to the consistent and predictable fulfillment of that request. A reliable API is one that consistently delivers the expected response without unexpected errors, data corruption, or unforeseen breaking changes. Type systems do help ensure program correctness by catching many errors at compile time but typed systems still face reliability challenges when it comes to APIs - the challenges often emerge when data moves between different applications, databases, and services.
Fundamentally, correctness comes before reliability. Building programs that meet their specifications is precisely what the Software Development Life Cycle (SDLC) is designed to ensure. Life would have been perfect if we could follow something like the Waterfall model, where requirements are defined once upfront, implementation follows those requirements exactly, and nothing ever changes. But requirements do change, and sometimes those changes can be brutal: a simple field addition or removal (not to talk of fields or whole sections) becomes a cascade of breaking changes across frontend, backend, and database layers.
Imagine this playing out in a sprint cycle: requirements are made, the program is built, the API is documented, and I truly hope you document your API; don't be an engineer whose API contract lives solely in Slack messages. Then, requirements change. This might be due to no fault of the PM or designer (even though sometimes, holding them by the shirt can feel like a justified reaction). The code gets updated, but the documentation is left unchanged in the spirit of meeting sprint deadlines. Before you know it, correctness is maintained but reliability goes down the drain. Then, your frontend partner is in your DMs saying, "Bro, you broke my page" – and it's worse when your API is consumed outside your organization. Trust gets eroded, leading to the frontend team often building a validator between the backend and frontend, which adds an unnecessary layer of complexity to the whole system. Sometimes, even defined types and tests don't catch these changes, often due to subtle abuses of the type system itself.
The Illusion of Convenience: Type Casting, ORMs, and the Broken Contract
I think life would have also been easier if our API request/response (data) shapes could always perfectly match the database schema. But that's rarely the reality, or even the right design choice. For instance, I personally love handling related properties as nested objects in API requests and responses. The problem? This almost never translates directly to how data is spread out in database tables. Sometimes, there's a need to pull information from multiple, related tables just to build that single object for the API response. This means that in a typical system, your database model type cannot serve as your service's return type directly. You really have to create different interfaces or shapes for your service's return types. But mocking up numerous types, especially in a large codebase, can feel like a chore. That's when the devil whispers, "Just type cast, bro."
This temptation is where Type Casting ≠ Contract becomes a critical truth. Casting is essentially saying: This old shape goes to void
(meaning, I'm ignoring its previous context), and now it's this new shape. The compiler loses all context of the old shape and blindly trusts your judgment of the new one. And when the shapes are close enough, type casting is allowed, masking the underlying issues. I have to add, that I do not think (most times) you should type cast anything beyond scalar values. Complex structures that hold multiple values should always go through the normal process of type narrowing and explicit mapping.
Consider these two code snippets from a typed system:
// Explicitly defining return types: reflects only the selected fields.
const [queryResult, countQueryResult] = await Promise.all([
AppDataSource.query(unionQuery, queryParams) as Promise<{id: UUID; name: string; category: string}[]>,
AppDataSource.query(countQuery, countParams) as Promise<{count: string}[]>
]);
// Relies on ORM's wide types and casting
// This returns a wide type (the DB model) of type Country, even with another entity
// (Visa Information) attached and only a few fields explicitly selected.
const countries = await this.countryRepository.findAllRecordWithCount({
select: { id: true, commonName: true, code: true, slug: true,
visaInformation: { id: true, durationOfStay: true},
},
params: {
deletedAt: IsNull(),
},
relations: {
visaInformation: true,
},
offSet: options.offSet,
limit: options.limit,
order: {commonName: "ASC"},
});
You might be tempted to simply define the return type of the service for countries to be Country[]
(the full DB model). You could then do some operations down the line (perhaps attach an image to each country), cast it again as Country
, and return. Voila! The compiler allows you to pass through because the shape is close enough just to satisfy its checks. Sometimes, you're even pushed to do as unknown as Country
just to silence it.
So, down the line, when requirements change, the compiler often doesn't catch any breaking change because you've essentially defined a wide type to accommodate all these potential mutations. Correctness is maintained for your code, but the API contract subtly breaks, leading to those "bro, you broke my page"
moments. In essence, while the ORM makes our lives easier for database operations, it also creates a powerful temptation to violate this principle: DB model ≠ Request/Response shape.
API-First Development
This approach is quite self explanatory: put the API first. It means specifying your API's contract before you write the code. Your API should be a first class citizen, the source of truth, a fore thought and not an after thought. The url structure, request, response shape and all other invariants associated with the API should be defined before code is written. This feels counter intuitive, a typical workflow goes: code => documentation
while this says documentation => code
. It might feel like more upfront work, but it truly paves the way for a reliable API. Here's how: when your contract is defined upfront, any deviation from that contract becomes immediately visible. No more silent failures where your API returns unexpected data structures, missing fields, or wrong types. The contract becomes an enforcer, not just documentation. When your API implementation drifts from the specification, tools can catch it immediately rather than letting broken endpoints silently return malformed data to unsuspecting clients.
Another advantage that cannot be gainsaid is better collaboration. A typical collaboration workflow might be requirements definition => task assignment => assigned developer codes => code reviews => code merged => upstream (frontend, QA) work continues
. But oh boy, do we have the "can you adjust the data shape?" situations, or the "a field is missing," or "the URL is wrong" emergencies. With API-First, the documentation/contract gets produced first. All stakeholders approve it before code is written, ensuring all upstream tasks can happen in concert with the precise definition of the API.
One of the most powerful tools for this API-First implementation is the OpenAPI Specification. It's truly just a formal, machine-readable, and human-readable standard for describing an API. It defines everything from its endpoints and parameters to its request and response shapes. And, precisely because it's a formal, machine-readable standard, tools can perform powerful validations and even automatically generate code and types directly from it.
This leads us to what I see as the third, and perhaps most compelling, advantage of API-First development (this one truly speaks to me): a significant reduction in overall system complexity.
Your entire data flow can be simplified down to this: DB Model <> Service/DTO <> OpenAPI Types (Spec)
This means no more tedious, manual mocking up of different interfaces or types to define your services. The OpenAPI spec becomes the single source for your API contract. Validation at service boundaries becomes simplified too, as OpenAPI specification handles structural validation, ensuring incoming and outgoing data adheres to that very same, precise contract.
Putting it into Practice: OpenAPI in Action
Now that we've covered the why of API-First and OpenAPI, let's dive into the how. We won't be building a complete setup step by step, but I'll highlight key parts of a robust, modular OpenAPI configuration,.
// index.ts
import swaggerJsdoc from "swagger-jsdoc"// Imports the swagger-jsdoc library to generate OpenAPI spec from YAML files
import { tags } from "./tags"; // Imports a tags array, which defines categories for API endpoints (e.g., Users, Products)
import fs from "fs"; // Node.js File System module for reading and writing files
import yaml from "js-yaml"; // Imports js-yaml for parsing YAML content
/**
* Loads and parses a YAML file synchronously.
* @param {string} filePath - The path to the YAML file.
* @returns The parsed JavaScript object from the YAML content.
*/
const loadYAML = (filePath: string) => yaml.load(fs.readFileSync(filePath, "utf8"));
/**
* Loads a specific component (e.g., schemas, parameters, responses) from a YAML file.
* @param {string} path - The path to the YAML file containing the component.
* @param {string} key - The specific key under components to extract (e.g., schemas, parameters).
* @returns The extracted component object, or an empty object if not found.
*/
const loadComponent = (path: string, key: string): any => {
const data: any = loadYAML(path); // Loads the entire YAML file
return data?.components?.[key] ?? {}; // Safely access the component by key, return empty if not found
};
// Define the base path where OpenAPI components (schemas, parameters, responses) are stored.
const basePath = `your base path`;
// Load individual OpenAPI components from their respective YAML files.
const loadedParameters = loadComponent(`${basePath}/parameters.yml`, "parameters");
const loadedSchemas = loadComponent(`${basePath}/schemas.yml`, "schemas");
const loadedResponses = loadComponent(`${basePath}/responses.yml`, "responses");
// Configures swagger-jsdoc to build the OpenAPI specification.
const options = {
definition: {
openapi: "3.0.0",
info: {
title: "OpenApi Spec",
version: "1.0.0",
},
servers: [
{
// Define API server URLs. This is where your API will be hosted.
url: `your defined host name`,
description: "V1 Server",
},
],
tags, // Includes the imported tags, which are defined in ./tags.ts.
components: {
// Reusable components for your OpenAPI specification.
securitySchemes: {
// Defines security schemes, e.g., API keys, OAuth2.
authToken: {
type: "apiKey", // Type of security scheme (e.g., apiKey, http, oauth2, openIdConnect).
in: "header", // Where the API key is sent (e.g., query, header, cookie).
name: "Authorization", // The name of the header/query parameter/cookie.
},
},
parameters: loadedParameters, // Integrates reusable parameters loaded from parameters.yml.
schemas: loadedSchemas, // Integrates reusable data schemas loaded from schemas.yml.
responses: loadedResponses, // Integrates reusable response definitions loaded from responses.yml.
},
},
// Specifies the paths to files containing YAML definitions
apis: [`documentation_path/*.yml`],
};
// Generate the OpenAPI specification object using the defined options.
const openapiSpec = swaggerJsdoc(options);
// Writes the generated OpenAPI specification to a JSON file
fs.writeFileSync(
`output_file_path/generated/api-spec.json`, // The output file path.
JSON.stringify(openapiSpec, null, 2) // Converts the JS object to a pretty printed JSON string.
);
export default openapiSpec;
// tags.ts (hypothetical values)
export const tags = [
{ name: "Users", description: "Endpoints for managing user accounts and profiles" },
{ name: "Products", description: "Endpoints for product catalog and inventory" },
{ name: "Orders", description: "Endpoints for handling customer orders and transactions" },
{ name: "Payments", description: "Endpoints for processing payments and financial records" },
{ name: "Authentication", description: "Endpoints for user login, registration, and authorization" },
{ name: "Notifications", description: "Endpoints for sending and managing notifications" },
{ name: "Search", description: "Endpoints for search functionalities" },
{ name: "Settings", description: "Endpoints for application and user settings" },
];
# parameters.yml
# This file defines an example of a reusable API parameter according to the OpenAPI Specification.
# By defining parameters here, they can be referenced across multiple API endpoints,
# ensuring consistency and reducing duplication. Each parameter specifies its name,
# where it's located, whether it's required, its description, and its data schema.
components:
parameters:
Id:
name: id
in: path
required: true
description: Unique identifier for the resource.
schema:
type: string
format: uuid # Specifies the string should conform to UUID format.
example: "123e4567-e89b-12d3-a456-426614174000"
# schemas.yml
# This file defines reusable data schemas (data models) for your API's requests and responses,
# By centralizing schemas here, you ensure data consistency across your API and enable tools to validate data and generate types.
# Each schema specifies the expected properties, their types, formats, and examples.
components:
schemas:
# Example: A generic schema defining the structure for a successful creation response.
# This schema indicates that a data object will be returned, containing the id
# of the newly created resource and a success message.
CreateResponseSchema:
description: Create response schema
type: object
properties:
data:
type: object
properties:
id:
type: string
format: uuid # Specifies the string should conform to UUID format.
example: "123e4567-e89b-12d3-a456-426614174000"
message:
type: string
example: "Resource created successfully"
required: ["id", "message"] # Both id and message are mandatory within data.
required: ["data"] # The data property itself is mandatory in the response.
# responses.yml
# This file defines reusable API response objects, adhering to the OpenAPI Specification.
# By centralizing common responses here (like success messages, validation errors, or server errors),
# you can easily reference them across different API endpoints, ensuring consistency in your
# API's error handling and standard responses. Each response specifies its description,
# content type, and the schema it adheres to.
components:
responses:
# Example: A reusable response definition for an internal server error (HTTP 500).
# It specifies a description, the content type (application/json), and references
# a separate ErrorResponseSchema for its data structure.
InternalServerError:
description: Internal Server Error # A description of the response.
content:
application/json: # Specifies the media type of the response body.
schema:
# References a schema defined in schemas.yml
$ref: "#/components/schemas/ErrorResponseSchema"
example:
# An example of what the response body for this error would look like.
error:
type: "INTERNAL_SERVER_ERROR"
message: "An unexpected error occurred. Please try again later."
# users.yml
# This file defines API endpoints (paths) related to user management.
# Each path describes the HTTP methods it supports (e.g., POST, GET),
# the parameters it accepts, the request body it expects, and the
# various responses it can return.
# It leverages reusable components defined in other YAML files (like schemas.yml, parameters.yml).
paths:
base_url/users:
post:
# Defines a POST operation to create a new user.
summary: Create a new user
operationId: createUser # Unique string used to identify the operation.
tags: [Users] # Associates this operation with the Users tag for grouping in documentation.
requestBody:
# Describes the expected structure of the request body.
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUserRequestSchema"
responses:
# Defines possible responses for this operation.
"201":
description: User created successfully
content:
application/json:
schema:
# References the generic success response schema for creation.
$ref: "#/components/schemas/CreateResponseSchema"
"400":
description: Invalid input
content:
application/json:
schema:
# References a general error schema for bad requests.
$ref: "#/components/schemas/BadRequestErrorSchema"
"500":
# References a reusable Internal Server Error response defined in responses.yml.
$ref: "#/components/responses/InternalServerError"
base_url/users/{id}:
get:
# Defines a GET operation to retrieve a single user by their ID.
summary: Get user by ID
operationId: getUserById
tags: [Users]
parameters:
# References the reusable Id parameter defined in parameters.yml.
- $ref: "#/components/parameters/Id"
responses:
"200":
description: User data retrieved successfully
content:
application/json:
schema:
# References a schema for the successful user response.
$ref: "#/components/schemas/UserResponseSchema"
"404":
description: User not found
content:
application/json:
schema:
# References a general error schema for not found resources.
$ref: "#/components/schemas/NotFoundErrorSchema"
"500":
$ref: "#/components/responses/InternalServerError"
components:
schemas:
# Schema for the request body when creating a new user.
CreateUserRequestSchema:
type: object
properties:
name:
type: string
example: David Oluwatobi
description: Full name of the user.
email:
type: string
format: email
example: david@gmail.com
description: User's email address.
password:
type: string
format: password
example: SecureP@ssw0rd!
description: User's chosen password.
required: [name, email, password]
# Schema for the successful response when retrieving user data.
UserResponseSchema:
type: object
properties:
id:
type: string
format: uuid
example: "a1b2c3d4-e5f6-7890-1234-567890abcdef"
description: Unique identifier of the user.
name:
type: string
example: David Oluwatobi
description: Full name of the user.
email:
type: string
format: email
example: david@gmail.com
description: User's email address.
required: [id, name, email]
The Power of Automation (and a Crucial Caveat!)
That sure feels like a lot of work!. Manually maintaining all these YAML files might seem daunting. But this is where the real power of OpenAPI automation kicks in. Once your spec is defined, you can leverage tools to generate code and types from it. For instance, you can run a single command that first generates your OpenAPI spec (using the index.ts script) and then immediately uses it to generate TypeScript types:
npx ts-node -r tsconfig-paths/register base_path/index.ts && \
npx openapi-typescript@latest base_path/generated/api-spec.json --output base_path/types/generated/type.d.ts
However, here's a critical caveat you need to heed. While these generation processes are incredibly powerful, they should almost always be run as a script in development mode and never as a live endpoint on a running server. Operations like reading multiple files, parsing YAML, performing complex string operations (especially those involving regex with backtracking), and writing files (especially synchronously) can block Node.js's single threaded event loop and worker threads. This means while your server is busy generating a spec or types, it won't be able to process other incoming requests: making it a prime target for a Denial of Service (DoS) attack
Getting Types for Service Use
The type.d.ts
file generated by openapi-typescript
contains all the TypeScript types needed for your API. Helper utility types can be defined to work with the generated nested types.
// base_path/types/generated/type.d.ts
import { components, paths } from "./generated/type"; // Adjust path as per your project
// Utility type to extract the JSON response schema for a specific path, method, and status code.
// P: Path (e.g., "/users/{id}")
// M: HTTP Method (e.g., "get", "post", "put")
// S: HTTP Status Code (e.g., 200, 201, 400)
type APIResponse<
P extends keyof paths,
M extends keyof paths[P],
S extends keyof (paths[P][M] extends { responses: any } ? paths[P][M]["responses"] : never)
> = P extends keyof paths
? M extends keyof paths[P]
? paths[P][M] extends { responses: any }
? S extends keyof paths[P][M]["responses"]
? paths[P][M]["responses"][S] extends { content: any }
? paths[P][M]["responses"][S]["content"] extends { "application/json": any }
? paths[P][M]["responses"][S]["content"]["application/json"]
: never
: never
: never
: never
: never
: never;
// Utility type to extract the JSON request body schema for a specific path and method.
// P: Path (e.g., "/users")
// M: HTTP Method (e.g., "post", "put")
type APIRequest<
P extends keyof paths,
M extends keyof paths[P]
> = P extends keyof paths
? M extends keyof paths[P]
? paths[P][M] extends { requestBody: { content: any } }
? paths[P][M]["requestBody"]["content"]["application/json"]
: never
: never
: never;
// Examples for the Users Endpoint
// Type for the request body when creating a new user (POST /users)
export type TUserCreateRequest = APIRequest<"/users", "post">;
// Type for the successful response when retrieving a single user (GET /users/{id})
export type TUserResponse = APIResponse<"/users/{id}", "get", 200>;
// This type directly represents the structure of your generic creation success response.
export type TGenericCreateResponse = components["schemas"]["CreateResponseSchema"];
// These types represent common error response structures, reusable across your API.
export type TNotFoundError = components["schemas"]["NotFoundErrorSchema"];
SDK/Client Generation for API Consumers
Instead of consumers of the API manually crafting HTTP functions, they can simply import a generated client library (from the spec). This provides:
Consistency: The client is always in sync with the latest API contract.
Ease of Use: Method calls with clear parameters and typed responses, drastically reducing integration time and errors.
Reduced Boilerplate: Consumers spend less time on HTTP related code.
To ensure API consumers always have the latest SDK, client generation can be automated with GitHub Actions. A workflow, triggered by pushes or pull requests to your main branch, can use git diff
to detect changes in the spec file. Upon change, a tool like openapi-typescript-codegen
generates the client, ready to be published to GitHub NPM Registry.
Subscribe to my newsletter
Read articles from David Oluwatobi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

David Oluwatobi
David Oluwatobi
A software engineer struggling to keep up with his writing schedule