DEV Community

Cover image for Leveraging the TS type system with APIs: Validation, error handling and free OpenAPI specs
Pete McFarlane
Pete McFarlane

Posted on

Leveraging the TS type system with APIs: Validation, error handling and free OpenAPI specs

How many times have you been frustrated by out of date or incomplete API documentation? How many times have you realised that you were responsible for the out of date information?!
Building and maintaining API documentation that is an up-to-date reflection of the API itself can be a chore. Input validation and error handling can be hard to maintain, tricky to test, prone to inconsistencies, and easy to overlook. I'm going to explore how we can leverage the TypeScript type system to make these problems less of an issue, whilst generating API specifications for free! ๐Ÿ’ธ

Common API issues

Inaccurate documentation

One thing worse than missing API documentation is out-of-date API documentation. If the documentation is written in a separate location to the API code then it becomes very manual to keep both in sync with each other. Even if as a developer making a change you remember to update the documentation, you could still introduce a subtle mistake. A common user mistake I've experienced for example is forgetting to nest a response value with a { data: {} } wrapper or similar.

Overlooking input validation

Then there's maintaining input validation - what if your API is expecting a UUID as an input but you're accidentally sending an auto-incrementing number, or post slug? What if your application just expects the input payload to be correct and complete, but it's missing required fields? You usually have to build a validation layer, so the API will respond to any incoming user request, then the validation layer will check if all the data is present and correct before continuing to handle the request.

In some codebases I've seen these validation checks repeated in multiple, subtly different ways - because it's more boiler plate for devs to manage and maintain, and it's often easy to introduce individual validation rules for each specific endpoint. This increases cognitive load for devs too as they have to handle the cases where the input is invalid, on-top of all other edge cases.

Inconsistent error handling

Then there's error handling - is there an issue with an invalid input? Or maybe the input is fine, but we've forgotten to authenticate the request? Perhaps the entity that was requested doesn't exist, or there's a foreign key constraint in the database, or an incorrect value selected for an enum, an input string that's too long - or another internal API call that isn't responding as we expect - there's a myriad of runtime errors that we need to think about handling and testing. If we want to test at the controller level, that typically means mocking or stubbing the request and response objects so we can monitor the behaviour.

Our Solution: TSOA

(Pronounced soยทuh) is a Typescript library that generates OpenAPI compliant APIs and specs (OpenAPI specification is a language-agnostic definition format used to describe RESTful APIs), that can be added to your Express, Hapi, Koa or other NodeJS frameworks. It leverages the type system, meaning requests and responses are just represented as types. Extra details are added with annotations, e.g. @Route('/api/entities') or @Get('{entityId}). It removes the boiler plate for input validation and response or error handling, which also makes testing simpler. It can also generate API specs for us, meaning the docs are an up-to-date and accurately represent our API.

Example API definition

Lets say I have an API route that expects an entity id in the path. The entity should be in the format of a UUID. I can define this like so:

import { Controller, Get, Path, Route } from 'tsoa';

/**
 * Stringified UUIDv4.
 * See [RFC 4112](https://tools.ietf.org/html/rfc4122)
 * @pattern [0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}
 * @example "52907745-7672-470e-a803-a2f8feb52944"
 */
export type UUID = string;

export class EntityController extends Controller {
  /**
   * Retrieves an entity by its ID.
   */
  @Route('/entities/{entityId}')
  @Get()
  public async getEntity(
    @Path() entityId: UUID
  ): Promise<Entity> {
    // ... your logic to fetch the entity
    return this.entityService.getEntity(entityId);
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice how our function getEntity doesn't take a Request/Response/Next parameter like a typical Express callback? We're just using plain old fashioned Typescript Types, e.g return Promise<Entity> - this not only cleans up our code and is less to think about, it's also simpler to test.

Testing

Speaking of testing, let's look at a simple example of how we can test the getEntity method and its error handling using a testing framework like Jest:

describe('EntityController', () => {
  let mockEntityService: any;
  let controller: EntityController;
  const entityId = '52907745-7672-470e-a803-a2f8feb52944';

  beforeEach(() => {
    mockEntityService = {
      getEntity: jest.fn(),
    };
    controller = new EntityController(mockEntityService);
  });

  it('should return an entity if found', async () => {
    const mockEntity = { id: entityId, name: 'Test Entity', createdAt: new Date() };
    mockEntityService.getEntity.mockResolvedValue(mockEntity);

    const result = await controller.getEntity(entityId);
    expect(result).toEqual(mockEntity);
    expect(mockEntityService.getEntity).toHaveBeenCalledWith(entityId);
  });

  it('should throw HTTPError with 404 status if entity is not found', async () => {
    mockEntityService.getEntity.mockResolvedValue(null);

    await expect(controller.getEntity(entityId)).rejects.toBeInstanceOf(HTTPError);
    await expect(controller.getEntity(entityId)).rejects.toHaveProperty('status', 404);
    await expect(controller.getEntity(entityId)).rejects.toHaveProperty('message', 'Entity not found');
    expect(mockEntityService.getEntity).toHaveBeenCalledWith(entityId);
  });
});
Enter fullscreen mode Exit fullscreen mode

In this test suite, notice how we are interacting directly with the controller's method and its TypeScript type signature. We are not setting up or manipulating mock req or res objects, making our tests more focused and easier to understand.

Error handling

The TSOA library is now going to make sure that any input (in the path, params, body or headers) matches what we've defined under our @Route annotation. But other errors can occur - for example, we might ask the entity service for an entity that doesn't exist! It'd be nice if, instead of exploding with a 500 unknown error, we could return a 404 not found with a helpful error message. I created a HttpError class to make managing these errors easier

export class HTTPError extends Error {
  constructor(message: string, public readonly status: number) {
    super(message);
  }
}
Enter fullscreen mode Exit fullscreen mode

And here's a slightly simplified version of my error handler

export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
  if (err instanceof ValidateError) {
    const body: ErrorResponse = {
      errors: {
        detail: {
          message: err.message,
          fields: err.fields,
        },
      },
    };
    res.status(422).json(body);
    next();
    return;
  }
  if (err instanceof HTTPError) {
    const body: ErrorResponse = {
      errors: {
        detail: err.message,
      },
    };
    res.status(err.status).json(body);
    next();
    return;
  }

  const status = err.status || 500;
  const body: ErrorResponse = {
    errors: {
      detail: err.message || 'Internal Server Error',
    },
  };

  res.status(status).json(body);
  next();
};
Enter fullscreen mode Exit fullscreen mode

Authentication & Middleware

You can require authentication for certain routes by using the @Security('name', ['scopes']) decorator, where the name is preconfigured in a typescript module - much like any Express middleware. And you can add custom middleware to controllers or routes in much the same way, by using the @Middlewares decorator. One drawback here is the engineer has to remember to add those decorators!

Up-to-date generated API specs

And finally, because TSOA leverages your TypeScript types and decorators, it can automatically generate an OpenAPI-compliant specification (usually in JSON or YAML format) of your API. This generated specification can then be used by various tools and platforms, such as Swagger UI, code generation tools, and API gateways, ensuring your documentation is always up-to-date and accurate.

Example OpenAPI using SwaggerUI

Drawbacks

It does introduce another dependency and another layer of abstraction in your application, and there may inevitably be a small performance overhead, but it may be less than you manually handling input validation and error handling anyway. I think it outweighs the drawbacks, but if performance is the most critical choice, you probably aren't using a NodeJS/Express service anyway!

If you are looking to do more advanced input validation, beyond pattern matching on strings, or some validation that can't be defined statically, e.g. authorisation - if a user can access an entity by ID - then you will still have to perform these checks in your code.

Alternatives

Zod seems to be the go-to validation library, and is very useful in some contexts, for example if you need to do more complex or nested runtime validation. But this won't generate API docs or give you error handlers or API controllers.

I noticed the NestJS framework has a module for generating docs from types and annotations, see their docs for a worked example.

OpenApi3-ts is a TS library that helps you build OpenAPI specs, but it doesn't do error handling, validation or replace your controllers!

tRPC can be a good option for creating type-safe APIs, which borrows concepts from REST and GraphQL. But this adds different tRPC boiler plate and ways-of-doing-things, which might be great for a backend/frontend monorepo situation for example, but might be harder to create a public facing API.

Conclusion

TSOA is pretty simple to introduce to an existing NodeJS/Express app, and will give you input validation generated API documentation out-the-box. Give it a try and share your feedback, on this or other approaches that have worked (or not!) for you.

Top comments (0)