Oct 1, 2022 • 8 min read
Auto-generating OpenAPI documents with TypeScript interfaces
OpenAPI is a wonderful tool to explicitly document your REST API endpoints.
It’s also a particularly verbose YAML-based format that can be difficult to write by hand. Look at this “simple” example from the official docs. It’s almost as if the people who invented OpenAPI expected you to use a dedicated OpenAPI editor tool!
If you use TypeScript in your codebase, you might already have defined types such as request and response bodies for each of your endpoints:
export type CreateUserRequest = { name: string; email: string; password: string; } export type CreateUserResponse = { userId: string; }
If you want to document your API with OpenAPI, you then need to carefully replicate this information in YAML format. This can be tedious and error-prone. Can we do better?
In fact, we can! Projects such as tsoa, Deepkit and Spot take your TypeScript code, run some magic, and spit out OpenAPI on the other end. How? We’re about to find out!
TypeScript Compiler API
If you use TypeScript, you’re probably most familiar with the tsc
tool. However, the typescript
npm package offers much more than that. In particular, TypeScript lets you hook into its internals with the TypeScript Compiler API. Unlike the rest of the TypeScript project, this API is officially unstable. In practice, it’s however only seen minor breaking changes (see history). I’ve been using it since 2017, and I’ve only had to tweak my code once. Good job TypeScript team, as always :)
TypeScript Compiler API gives you the following functionality:
- parsing TypeScript source files and reading their AST (abstract syntax tree)
- type-checking TypeScript programs, with detailed access to syntactic and semantic errors
- inspecting the inferred type of any particular node in the AST
- transforming the AST and printing out the modified source code
- doing all kinds of shenanigans with virtual files and so on (even type-checking code entirely in the browser!)
For example, say we have the following TypeScript file:
// example.ts export type MyType = { foo: string; bar: number; };
We can use the following code to extract information about MyType
:
import path from "path"; import ts from "typescript"; const mainEntryPoint = path.join(__dirname, "example.ts"); const program = ts.createProgram([mainEntryPoint], {}); const typeChecker = program.getTypeChecker()!; // Parse the main source file and look for type definitions. const sourceFile = program.getSourceFile(mainEntryPoint)!; for (const statement of sourceFile.statements) { if (ts.isInterfaceDeclaration(statement)) { console.log(`Encountered an interface named: ${statement.name.text}`); const type = typeChecker.getTypeAtLocation(statement); console.log( `Properties: ${type .getProperties() .map((p) => p.name) .join(", ")}` ); } }
This will print the following:
Encountered an interface named: MyType Properties: foo, bar
We can go one step further and look at the type of each property:
for (const property of type.getProperties()) { console.log(`Property: ${property.name}`); console.log(typeChecker.getTypeAtLocation(property.valueDeclaration!)); }
Here is the output:
Property: foo { flags: 4, // matches the value of ts.TypeFlags.String id: 14, intrinsicName: 'string', objectFlags: 0 } Property: bar { flags: 8, // matches the value of ts.TypeFlags.Number id: 15, intrinsicName: 'number', objectFlags: 0 }
This also works with more complex types such as arrays, unions, and so on. For example, when type.isUnion()
is true
, you can iterate through type.types
to check each possible type.
We can go one step further and look at the inferred type of a particular variable. Say we have the following code:
// example.ts function f() { return 123 as const; } const myVariable = f();
We can check the type of myVariable
with:
const symbols = typeChecker.getSymbolsInScope( sourceFile, ts.SymbolFlags.Variable ); const symbol = symbols.find((s) => s.name == "myVariable")!; console.log( typeChecker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration!) );
This will tell us that TypeScript inferred its type as the number literal 123
:
{ checker: ... flags: 256, // matches the value of ts.TypeFlags.NumberLiteral id: 87, symbol: undefined, value: 123, regularType: ... freshType: ... }
This means that we can take any TypeScript code and introspect not only types, but also variables in the codebase.
From TypeScript to OpenAPI
Using this knowledge, it’s possible to transform TypeScript into JSON Schema, and by extension OpenAPI (which is 100% compatible with JSON Schema since v3.1).
We’re not going to implement this here because it would take a few thousand lines of code. Instead, let’s walk through how the open-source project tsoa
makes it all work.
First, let’s get familiar with the syntax that tsoa
uses and walk through the following example from the documentation:
@Route("users") export class UsersController extends Controller { @Get("{userId}") public async getUser( @Path() userId: number, @Query() name?: string ): Promise<User> { return new UsersService().get(userId, name); } @SuccessResponse("201", "Created") // Custom success response @Post() public async createUser( @Body() requestBody: UserCreationParams ): Promise<void> { this.setStatus(201); // set return status 201 new UsersService().create(requestBody); return; } }
How does tsoa
transform this into an OpenAPI document?
It all starts with the ControllerGenerator
class, which takes a controller definition and uses TypeScript to inspect arguments passed to each decorator. This extracts useful metadata such as each endpoint’s path, its HTTP method, query parameters, and so on. Each method (getUser
and createUser
here) gets processed by the MethodGenerator
class. Each method parameter (such as userId
, name
and requestBody
) is in turn processed by the ParameterGenerator
class.
The real magic happens in the TypeResolver
class, where the type of each parameter is resolved and converted to tsoa
's internal type model. This is where the TypeScript Compiler API really comes into play. Looking at the code, you can see that covering every possible type isn’t easy. That’s already a thousand lines of code, albeit very well-structured and readable.
Once the code has been processed through the ControllerGenerator
using the TypeResolver
, tsoa
has all the metadata it needs to output the corresponding OpenAPI document. Now, it’s just a matter of converting the metadata to the right format and output it as YAML. This happens in the generateSpec()
method, which in the case of OpenAPI 3 will invoke the SpecGenerator3
class. All that class does is convert from tsoa
's internal metadata format to the official OpenAPI 3 specification.
The cherry on top: automatic request validation
The beauty of a tool such as tsoa
is that it provides not only OpenAPI document generation, but also the ability to automatically validate incoming requests (see documentation). If a client attempts to send a payload field with a number
when it should be a string
for example, an error will be thrown before the endpoint is executed. We don’t need to write any validation code ourselves; that comes out of the box thanks to the types inferred by tsoa
with the TypeScript Compiler API.
While an invalid request could come from a malicious hacker trying to break your API, it could also very well be an error in your client code. This is where Highlight comes in. When an error is thrown, not only can you see the server-side error stack in Highlight, but you’ll be able to replay the session that led to this bug in the first place from the user’s perspective. Knowing what circumstances led to a particular bug can be essential to fix it!
Get the visibility you need
What will you use the TypeScript Compiler API for?
Generating OpenAPI documents is but one possible application of the TypeScript Compiler API. You could also use it to generate forms from types automatically, to auto-generate React component properties, and so on.
What will you use the TypeScript Compiler API for? Let me know and don’t hesitate to ask for help if you get stuck!
Other articles you may like
Aug 30, 2022 • 22 min. read