TypeScript Types Are Not Enough: Building a Runtime API Contract Boundary

typescript dev.to

TypeScript gives us a lot of confidence.

We define types, interfaces, and function signatures. Our editor helps us catch mistakes before we even run the application.

But there is one important place where TypeScript cannot protect us:

Data coming from outside our application.

API responses are runtime data.

The backend can change. A field can be missing. A value can have a different type. An unexpected response can reach our frontend.

And TypeScript will not warn us.

The TypeScript Safety Illusion

Imagine we have a simple user type:

type User = {
  id: string;
  name: string;
};
Enter fullscreen mode Exit fullscreen mode

Then we fetch a user:

const response = await fetch("/api/user");

const user = await response.json() as User;
Enter fullscreen mode Exit fullscreen mode

Everything looks fine.

The editor is happy.
The build passes.

But what if the API returns:

{"id":123}
Enter fullscreen mode Exit fullscreen mode

The problem is that TypeScript does not validate runtime values.

The as User syntax does not check anything. It only tells TypeScript:

"Trust me, this data has this shape."

But external data should not be trusted by default.

A Real Failure Scenario

Imagine we have a login endpoint.

Today, the backend returns:

{"accessToken":"abc123","user":{"id":"1","name":"John"}}
Enter fullscreen mode Exit fullscreen mode

The frontend has a matching type:

type LoginResponse = {
  accessToken: string;
  user: User;
};
Enter fullscreen mode Exit fullscreen mode

Everything works.

But later, the backend changes the response:

{"token":"abc123","user":{"id":"1","name":"John"}}
Enter fullscreen mode Exit fullscreen mode

The application still builds successfully.

The bug appears later:

session.accessToken
Enter fullscreen mode Exit fullscreen mode

becomes:

undefined
Enter fullscreen mode Exit fullscreen mode

The problem is not TypeScript.

The problem is that the application accepted external data without verifying the contract.

API Responses Are Untrusted Data

A useful way to think about APIs is:

Before data enters our application, we should verify that it matches the contract we expect.

This is where runtime validation becomes useful.

The Simple Approach

A common approach is validating responses manually:

const response = await axios.get("/users");

const user = userSchema.parse(response.data);
Enter fullscreen mode Exit fullscreen mode

This works.

But as the application grows, we start repeating the same pattern everywhere:

login()
logout()
getProfile()
getOrders()
updateUser()
...
Enter fullscreen mode Exit fullscreen mode

Every API call needs:

  • request validation
  • response validation
  • error handling

At some point, validation itself becomes something that needs structure.

Creating an API Contract Boundary

Instead of validating every request manually, I created a small API wrapper responsible for this boundary.

The idea is simple:

The API layer receives schemas for both sides:

type ApiRequestOptions<TResponse, TData> = {
  requestSchema?: ApiSchema<TData>;
  responseSchema: ApiSchema<TResponse>;
};
Enter fullscreen mode Exit fullscreen mode

Now API calls can define their contracts explicitly:

apiRequest({
  client,
  method: "post",
  url: "/staff/auth/login",
  data: payload,
  requestSchema: adminLoginSchema,
  responseSchema: adminLoginResponseSchema,
});
Enter fullscreen mode Exit fullscreen mode

The feature does not need to know how validation happens.

It only defines the contract.

Keeping Contracts In A Shared Validation Layer

One important part of this approach is where the contracts live.

Instead of defining request and response shapes separately inside every feature, schemas are kept in a shared validation package.

For example:

import {
  adminLoginSchema,
  adminLoginResponseSchema,
} from "@app/validations/admin/auth";
Enter fullscreen mode Exit fullscreen mode

Now the API call and the validation rules stay connected.

The API layer does not guess the shape of the data.

The contract defines it.

What The Boundary Actually Looks Like

Usually, we only think about validating responses.

But requests can be invalid too:

  • wrong field name
  • missing required value
  • incorrect format

Catching these problems before sending the request saves a round trip and makes the issue obvious immediately.

A simplified version of the API boundary looks like this:

async function apiRequest<TResponse, TData = unknown>({
  client,
  method,
  url,
  data,
  requestSchema,
  responseSchema,
}: ApiRequestOptions<TResponse, TData>): Promise<TResponse> {

  let requestData = data;

  if (requestSchema) {
    try {
      requestData = requestSchema.parse(data);
    } catch (error) {
      throw new ApiRequestValidationError(url, error);
    }
  }

  const response = await client.request({
    method,
    url,
    data: requestData,
  });

  try {
    return responseSchema.parse(response.data);
  } catch (error) {
    throw new ApiResponseValidationError(url, error);
  }
}
Enter fullscreen mode Exit fullscreen mode

The flow is always the same:

  1. Validate what goes out.
  2. Send the request.
  3. Validate what comes back.

Making Contract Failures Explicit

Instead of throwing generic errors, validation failures get their own error types.

For example:

export class ApiResponseValidationError extends Error {
  constructor(
    public readonly url: string,
    public override readonly cause: unknown,
  ) {
    super(`API response does not match the contract for ${url}.`);
    this.name = "ApiResponseValidationError";
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the application can understand what happened without inspecting error messages.

A response validation failure is not the same as:

  • a network failure
  • a timeout
  • a server error

The failure itself carries meaning.

Validation Errors Are Not All The Same

Validation tells us that something is wrong.

But knowing that something failed is only half of the problem.

We also need to understand why it failed.

A validation problem is different from a network problem.

For example:

type ClientErrorKind =
  | "api_request_validation"
  | "api_response_validation"
  | "api_network_error"
  | "api_timeout"
  | "api_server_error";
Enter fullscreen mode Exit fullscreen mode

This distinction helps debugging.

Instead of seeing:

API failed
Enter fullscreen mode Exit fullscreen mode

we can understand:

Response shape changed
Enter fullscreen mode Exit fullscreen mode

or:

Network request timed out
Enter fullscreen mode Exit fullscreen mode

or:

Frontend sent invalid data
Enter fullscreen mode Exit fullscreen mode

A consistent error model means the UI does not need to understand every possible failure source.

The API layer handles that complexity.

Error Reporting Should Also Be Safe

When adding error reporting, there is another problem:

Logs can accidentally expose sensitive information.

Errors might contain:

  • tokens
  • passwords
  • emails
  • phone numbers

So before reporting errors, sensitive values should be sanitized.

Example:

Bearer eyJhbGci...
Enter fullscreen mode Exit fullscreen mode

becomes:

Bearer [REDACTED]
Enter fullscreen mode Exit fullscreen mode

Observability is useful, but it should not create another security problem.

The Final Flow

The final architecture looks like this:

Conclusion

TypeScript is great at protecting our code.

But it cannot protect us from data crossing the application boundary.

APIs are runtime data, and runtime data needs runtime validation.

Adding a contract boundary between the API and the application helps us:

  • catch integration issues earlier
  • prevent invalid data from reaching application state
  • make errors easier to understand
  • build more reliable frontend systems

This approach also changed the way I think about frontend architecture:

API boundaries are not just places where data enters the application.

They are places where trust should be established.

The goal is not to eliminate every possible bug.

The goal is to make failures happen closer to their source, where they are easier to detect and fix.

Source: dev.to

arrow_back Back to Tutorials