Typescript with the OpenAPI specification

Time to read: 13 minutes

Summary

OpenAPI, formerly swagger is a specification for RESTful interfaces that helps developers and engineers write and consume an API.

Typescript is a strongly typed programming language built on Javascript.

Most Typescript projects that utilise HTTP interfaces rely on an HTTP interface. There are many ways to create an OpenAPI specification for that interface, that is strongly typed to your Typescript.

I have conducted a review of the existing tooling, highlighting three ways that typescript and the OpenAPI specification can interact.

Conclusion

If I had to pick, I would write my OpenAPI specification as a typed object, using typescript. Then I would want typechecking for my request/responses, but I would not want prescriptive code, and I would not use generation in the lifecycle, because it shouldn't be necessary.

Compeller

So, because of that I am writing compeller, here it is in action.

A strong typescript binding for your OpenAPI Schema that doesn't need generation and is not prescriptive in coding style

compeller in action with it's handy CLI

Typescript - OpenAPI Mapping

At a high level we might say that we can either be OpenAPI specification driven, or Typescript code driven. These two approaches provide the following options.

1️⃣ Define an OpenAPI specification, and from that, generate the Typescript types for the API domain.

2️⃣ Use decorators on Typescript to produce an OpenAPI specification from the API domain.

There is a wildcard, type 3, where you an bind the typescript to the OpenAPI document by placing all or parts of the OpenAPI specification inside typescript itself. This approach does not use generation, but instead uses type inference to ensure the types and their schema are in agreement.

3️⃣ Combine the OpenAPI specification, and API domain types, into a single Typescript object.


1️⃣ OpenAPI => Typescript

The team behind the OpenAPI specification provided an example here.

We have our specification file from the above link. So lets review some of the tools we can ue to get our types from the specification:

OpenAPI Generator

Link: https://github.com/OpenAPITools/openapi-generator

Generate clients, and stub servers in multiple languages from the OpenAPI specification.

  • Generate client
  • Generate server - No TS support
  • Generate types

Usage

The below example creates a typescript API Client.

docker run \
  --rm \
  -v "${PWD}:/local" \
  openapitools/openapi-generator-cli generate \
  -i /local/spec.json \
  -g typescript-axios \
  -o /local/openapi-generator/ts

The created client will dutifully represent your types; here is the NewPet schema as an interface, automatically generated by the tool:

/**
 *
 * @export
 * @interface NewPet
 */
export interface NewPet {
    /**
     *
     * @type {string}
     * @memberof NewPet
     */
    'name': string;
    /**
     *
     * @type {string}
     * @memberof NewPet
     */
    'tag'?: string;
}

There is not yet support for generating a typescript server.


OpenAPI Typescript Codegen

Link: https://github.com/ferdikoomen/openapi-typescript-codegen

Produce typescript api clients from OpenAPI specification. Build tooling is not Java

Supports:

  • Generate typescript clients

Usage

You can generate a client from the specification with:

npx openapi-typescript-codegen \
  --input ./spec.json \
  --output ./openapi-typescript-codegen \
  --exportSchemas true

The provided client can be used to fetch from the API:

import { PetsService } from './index';

const pets = await PetsService.listPets(5);

In this instance pets will be typed either as Pet | Error after the fetch promise resolves.


Openapi Typescript

Link: https://github.com/drwpow/openapi-typescript

Produce typescript interfaces, from OpenAPI schema

Supports:

  • Generate types

Usage

The below example creates Typescript types and interfaces that represent the API Document.

npx openapi-typescript spec.json \
  --output ./openapi-typescript/schema.ts

The created types include paths, operations, and components. Here is an example of the paths:

export interface paths {
  '/pets': {
    get: operations['listPets'];
    post: operations['createPets'];
  };
  '/pets/{petId}': {
    get: operations['showPetById'];
  };
}

The operations section is automatically generated. I can imagine this would be very useful.

You can build a simple response handler, that is not coupled to any particular framework like so:

import { operations } from './schema';

type showPetById = operations['showPetById'];

const response = <K extends keyof showPetById['responses']>(
  statusCode: K,
  body: showPetById['responses'][K]['content']['application/json']
) => {};

This binds the response codes to the response objects, which are inturn bound to your OpenAPI schema via the generation.


2️⃣ Typescript => OpenAPI

In this scenario we have our Typescript code, for our server, and we want to create an OpenAPI specification.

These decorator and binding methods require you to write your code in a specific way, which is restrictive, and not adoptable for existing large projects.

NestJS

link: https://docs.nestjs.com/openapi/introduction

NestJS is a progressive typescript framework for building backend applications.

Supports:

  • Rendering documentation
  • Adding docs to controllers using decorators
  • Express and Fastify support

The documentation for this tool is extensive, so I will leave it to their talented team to explain the usage.


TSOA

link: https://tsoa-community.github.io/docs/

TSOA generates routes from your Typescript code for koa, fastify or express

  • Rendering documentation
  • Adding docs to controllers using decorators
  • Express and Fastify support

This tool has code bindings and decorators, but also makes use of generation, so there is a lot of configuration overhead.


3️⃣ Typescript <=> OpenAPI

This collection of tools bring the OpenAPI specification domain into the typescript typing system.

This gives some benefits when developers are more used to receiving type hints. Whilst it prevents the schema from being a portable JSON document, you can always export the javascript object to a JSON or YAML file.

This method is in my opinion less opaque, as it does not use generation like method 1️⃣, and less prescriptive than method 2️⃣, because you can write your code however you should like.

Basically I am all in on method 3️⃣!

OpenAPI3 TS

Usage

The extensive type system of OpenAPI3-TS attempts to be true to the OpenAPI specification.

const spec: OpenAPIObject = {
  info: {
    title: 'Mandatory',
    version: '1.0.0',
  },
  openapi: '3.0.3',
  paths: {
    '/v1/version': {
      get: {
        responses: {
          '200': {
            description: 'example',
            content: {
              'application/json': {
                schema: {
                  type: 'string',
                },
              },
            },
          } as ResponseObject,
        },
      },
    } as PathItemObject,
  },
};

This has advantages over other type systems, because it will require a diligent documentation of the types to ensure the compiler agrees.


AJV JSONSchemaType

Link: https://ajv.js.org/guide/typescript.html#utility-types-for-schemas

This utility type can take the API models that you declare as type aliases, or interfaces, and infer a fully JSON schema for the type.

Supports

  • Inferring schema type from object type.

Usage

This example is borrowed from te documentation.

import Ajv, {JSONSchemaType} from "ajv"
const ajv = new Ajv()

interface MyData {
  foo: number
  bar?: string
}

const schema: JSONSchemaType<MyData> = {
  type: "object",
  properties: {
    foo: {type: "integer"},
    bar: {type: "string", nullable: true}
  },
  required: ["foo"],
  additionalProperties: false
}

// validate is a type guard for MyData - type is inferred from schema type
const validate = ajv.compile(schema)

// or, if you did not use type annotation for the schema,
// type parameter can be used to make it type guard:
// const validate = ajv.compile<MyData>(schema)

const data = {
  foo: 1,
  bar: "abc"
}

if (validate(data)) {
  // data is MyData here
  console.log(data.foo)
} else {
  console.log(validate.errors)
}

The benefit of this method, is there is a runtime binding between the schema validation, and the typing system.


JSON Schema to TS

Link: https://github.com/ThomasAribart/json-schema-to-ts

Given a schema object in typescript, the type it represents can be inferred.

Supports:

  • Inferring Schema to Object type

Usage

The FromSchema type requires the schema to be made a const, and this means the schema itself is missing type hints.

import { FromSchema } from 'json-schema-to-ts';

export const VersionSchema = {
  type: 'object',
  required: ['version'],
  additionalProperties: false,
  properties: {
    version: {
      type: 'string',
    },
  },
} as const;

export type Version = FromSchema<typeof VersionSchema>;

This is a little less convenient as you still need to know the OpenAPI specification, and the type will resolve to never with no error if you get it wrong.

Closing

Thanks for reading this review, if there are any issues with the links let me know 👍