How to Create OpenAPI Schemas and SDKs With TypeSpec
TypeSpec (opens in a new tab) is a brand-new domain-specific language (DSL) used to describe APIs. As the name implies you describe your API using a TypeScript-like type system, with language constructs such as model
for the structure or schema of your API's data, or op
for operations in your API. If you've used OpenAPI, these concepts likely sound familiar – this is because TypeSpec is also influenced by and generates OpenAPI.
So something that is like OpenAPI, and also generates OpenAPI specifications? You may be asking yourself, why does TypeSpec exist? Like many people, our initial reaction to TypeSpec was to reference the iconic XKCD strip:
However, after spending some time with it, we've come to understand the justification for a new DSL - we'll cover some of that shortly. We also ran into this young language's rough edges, and we'll cover those in detail, too.
Our end goal with this article is to create a high-quality TypeScript SDK. However, before we create an SDK, we'll need to learn how to generate an OpenAPI document based on a TypeSpec specification. For that, we need to learn TypeSpec, and there is no better way to get started learning a new language than by asking why it exists in the first place.
The Problem TypeSpec Solves
Code generation is a force multiplier in API design and development. When an executive unironically asks, "How do we 10x API creation?", the unironic answer is, " API-first design + Code generation."
API-first means specifying exactly what your application's programming interface will look like before anything gets built, code generation means using that definition to create documentation, server (stubs) and client libraries (SDKs).
As mentioned previously,OpenAPI is widely used for exactly this reason – it provides a human-readable (as YAML) specification format for APIs, and comes with a thriving ecosystem of tools and code generators. So if OpenAPI exists, what can TypeSpec add?
The fundamental problem TypeSpec aims to solve is that writing OpenAPI documents by hand is complex, tedious, and error-prone. The complexity often leads to teams to abandon an API-first approach and instead start by coding their API, and then extracting OpenAPI from the codebase when they get to the point where they need documentation and SDKs – a quasi-API-first approach.
Ultimately, OpenAPI isn't for everyone. Neither is TypeSpec for that matter. But for those who are immersed in the TypeScript ecosystem, TypeSpec may be a more natural fit than OpenAPI. And the more tools we have to help businesses create great APIs, the better.
TypeSpec Development Status
Before you trade in your OpenAPI YAML for TypeSpec, know that at the time of writing, TypeSpec is nowhere near as feature-rich and stable as OpenAPI. If you're designing a new API from scratch, taking the time to learn OpenAPI will benefit your team, even if TypeSpec one day becomes the most popular API specification language.
TypeSpec Libraries and Emitters
Developers can extend the capabilities of TypeSpec by creating and using libraries. These libraries can provide additional functionality, such as decorators, types, and operations, that are not part of the core TypeSpec language.
A special type of library in TypeSpec is an emitter. Emitters are used to generate output from a TypeSpec specification. For example, the @typespec/openapi3
library provides an emitter that generates an OpenAPI document from a TypeSpec specification.
When targeting a specific output format, such as OpenAPI, you can use the corresponding emitter library to generate the desired output. This allows you to write your API specification in TypeSpec and then generate the output in the desired format.
A Brief Introduction to TypeSpec Syntax
This guide won't give a complete introduction or overview of TypeSpec, but we'll take a brief look at the language's structure and important concepts in the context of generating SDKs.
Modularity in TypeSpec
The main entry point in TypeSpec is the main.tsp
file. This file has the same role as the index.ts
file in a TypeScript project.
Just like in TypeScript, we can organize code into files, folders, and modules, then import (opens in a new tab) these using the import
statement. This helps split large API specifications into smaller, more manageable parts. The difference between TypeScript and TypeSpec in this regard is that TypeSpec imports files, not code.
Here's an example of how you can import files, folders, and modules in TypeSpec:
We can install modules using npm, and use the import
statement to import them into our TypeSpec project.
Namespaces (opens in a new tab), another TypeScript feature that TypeSpec borrows, allow you to group types and avoid naming conflicts. This is especially useful when importing multiple files that define types with the same name. Just like with TypeScript, namespaces may be nested and span multiple files.
Namespaces are defined using the namespace
keyword, followed by the namespace name and a block of type definitions. Here's an example:
namespace MyNamespace { model User { id: string; name: string; }}
They may also be defined at the file level, using the namespace
keyword followed by the namespace name and a block of type definitions. Here's an example:
namespace MyNamespace;model User { id: string; name: string;}model Post { id: string; title: string; content: string;}
Models in TypeSpec
Models (opens in a new tab) in TypeSpec are similar to OpenAPI's schema
objects. They define the structure of the data that will be sent and received by your API. We define models using the model
keyword, followed by the model name and a block of properties. Here's an example:
Models are composable and extensible. You can reference other models within a model definition, extend a model with additional properties, and compose multiple models into a single model. Here's an example of model composition:
The equivalent OpenAPI specification for the User
model above would look like this:
Operations in TypeSpec
Operations (opens in a new tab) in TypeSpec are similar to OpenAPI operations. They describe the methods that users can call in your API. We define operations using the op
keyword, followed by the operation name. Here's an example:
Interfaces in TypeSpec
Interfaces (opens in a new tab) in TypeSpec group related operations together, similar to OpenAPI's paths
object. We define interfaces using the interface
keyword, followed by the interface name and a block of operations. Here's an example:
The equivalent OpenAPI specification for the Users
interface above would look like this:
Decorators in TypeSpec
Decorators (opens in a new tab) in TypeSpec add metadata to models, operations, and interfaces. They start with the @
symbol followed by the decorator name. Here's an example of the @doc
decorator:
Decorators allow you to add custom behavior to your TypeSpec definitions using JavaScript functions. You can define your own decorators (opens in a new tab) or use built-in decorators provided by TypeSpec or third-party libraries.
Learn More About TypeSpec
The language features above should be enough to help you find your way around a TypeSpec specification.
If you're interested in learning more about the TypeSpec language, see the official documentation (opens in a new tab).
We'll cover more detailed examples of TypeSpec syntax in our full example below.
Generating an OpenAPI Document from TypeSpec
Now that we have a basic understanding of TypeSpec syntax, let's generate an OpenAPI document from a TypeSpec specification.
The example below will guide you through the process of creating a TypeSpec project, writing a TypeSpec specification, and generating an OpenAPI document from it.
For a speedrun, we've published the full example in a GitHub repository (opens in a new tab).
Step 1: Install the TypeSpec Compiler CLI
Install tsp
globally using npm:
Step 2: Create a TypeSpec Project
Create a new directory for your TypeSpec project and navigate into it:
Run the following command to initialize a new TypeSpec project:
This will prompt you to select a template for your project. Choose the Generic REST API
template and press enter. Press enter repeatedly to select the defaults until the project is initialized.
Step 3: Install the TypeSpec Dependencies
Install the TypeSpec dependencies using tsp
:
We'll need to install the @typespec/versioning
and @typespec/openapi
modules to generate an OpenAPI document. Run the following commands to install these modules:
Step 4: Write Your TypeSpec Specification
Open the main.tsp
file in your text editor and write your TypeSpec specification. Here's an example of a simple TypeSpec specification:
We start by importing the necessary TypeSpec modules.
These modules are provided by the TypeSpec project, but are not part of the core TypeSpec language. They extend the capabilities of TypeSpec for specific use cases.
By writing three using
statements (opens in a new tab), we expose the contents of the Http
, OpenAPI
, and Versioning
modules to the current file. This allows us to use the functionality provided by these modules in our TypeSpec specification with less code.
Without these statements, we can still access the functionality of the modules by using the fully qualified names of the types and functions they provide.
For example, instead of writing @operationId
, we could write TypeSpec.OpenAPI.operationId
to access the operationId
decorator provided by the OpenAPI
module.
Next, we define the BookStore
namespace, which will contain all the models, interfaces, and operations related to the bookstore API. Namespaces are used to group related types and operations together, and avoid naming conflicts.
This is a file-level namespace (it has no code block delimiters – no {
and }
), which means it spans the entire file.
Taking a step back, we see that the BookStore
namespace is decorated with the @service
decorator (opens in a new tab) from the TypeSpec core library.
The @service
decorator marks the namespace as a service, and provides the API's title.
We decorate the BookStore
namespace with the @info
decorator (opens in a new tab) from the @TypeSpec.OpenAPI
library to provide information for the OpenAPI document's info
object.
TypeSpec's @versioned
decorator (opens in a new tab) from the @TypeSpec.Versioning
library specifies the versions of the API.
We define a single version, 1.0.0
, for the API, but you can define multiple versions if needed.
We add a @server
decorator to the BookStore
namespace, which specifies the base URL of the API server: http://127.0.0.1:4010
.
This is the default Prism (opens in a new tab) server URL here, but you can replace this with the actual base URL of your API server.
Finally, our BookStore
namespace is decorated with the @doc
decorator (opens in a new tab) from the TypeSpec core library, which provides a description of the API.
In the OpenAPI 3 emitter, this description will be used as the description
field in the OpenAPI document's info
object.
This brings us to our first model, PublicationBase
, which represents the base model for books and magazines in the store.
Here we see how models are defined in TypeSpec using the model
keyword, followed by the model name and a block of properties.
Property types are similar to those in OpenAPI, but with some nuances. For example, float32
is used instead of number
, and utcDateTime
is used for date-time values.
See the TypeSpec data types documentation (opens in a new tab) for more information on the available data types. We should also educate ourselves about how these data types are represented in the OpenAPI document (opens in a new tab).
The type
property is defined as an enum
(opens in a new tab) to represent the type of publication. This is a custom enum defined within the BookStore
namespace.
Next, we define constants, BookExample1
and BookExample2
, using the #{}
syntax. We'll use these examples to demonstrate the structure of the Book
model.
On their own, these constants are not part of the model, nor will they be emitted by the OpenAPI emitter. We have to pass them as values in the @example
decorator to include them in the OpenAPI document.
This is the Book
model, which extends the PublicationBase
model. We use the @example
decorator to provide an example value for the model.
The extends
keyword causes the Book
model to inherit properties from the PublicationBase
model, with the ability to add additional properties specific to books, or override existing properties.
Magazines are represented by the Magazine
model, which also extends the PublicationBase
model. We provide example values for the Magazine
model using the @example
decorator.
Note how the Magazine
model adds properties specific to magazines, such as issueNumber
and publisher
.
To represent both books and magazines in a single model, we define a Publication
union type (opens in a new tab). The Publication
model is a union of the Book
and Magazine
models, with a discriminator property type
to differentiate between the two.
The @discriminator
decorator specifies the property that will be used to determine the type of the publication.
The @oneOf
decorator (opens in a new tab) is specific to the OpenAPI 3 emitter, and indicates that the Publication
schema should reference the Book
and Magazine
schemas using the oneOf
keyword instead of allOf
.
The Order
model represents an order for publications in the store. It contains familiar properties much like those of the Publication
models, except for a reference to the Publication
model in the items
property.
The items
property is an array of publications, which can contain both books and magazines.
Moving on to operations, let's start with the Publications
interface, which wraps operations for managing publications in the store.
We decorate the Publications
interface with the @tag
decorator (opens in a new tab) from the standard library to specify the tag for the operations in the interface. Tags are used to group related operations in the OpenAPI document, and can be applied to interfaces, operations, and namespaces.
Since we are using the OpenAPI 3 emitter, the @tag
decorator will be used to generate the tags
field in the OpenAPI document.
The @route
decorator (opens in a new tab) provided by the @TypeSpec.Http
library specifies the path prefix for the operations in the Publications
interface.
In the Publications
interface, we define three operations: list
, get
, and create
.
Let's focus for a moment on what we don't see in the operation definitions.
Note how the op
keyword is optional when defining operations within an interface. The operations are defined directly within the interface block, without the need for the op
keyword.
The operations are also defined without an HTTP method, such as GET
or POST
. This is because the default HTTP method for an operation is determined by the operation's parameters.
If an operation contains a @body
parameter, it defaults to POST
. Any operation without a @body
parameter defaults to GET
.
The get
operation in the Publications
interface takes a string
parameter id
and returns a Publication
or an Error
.
Note how the @path
decorator is used to specify that the id
parameter is part of the path in the URL.
This operation will have the path /publications/{id}
in the OpenAPI document. TypeSpec will automatically generate the path parameter for the id
parameter.
Examples for operation parameters and return types are provided using the @opExample
decorator from the standard library. These examples will be included in the OpenAPI document to demonstrate the structure of the request and response payloads.
Note that this functionality, at the time of writing (with TypeSpec 0.58.1), is not yet fully implemented in the OpenAPI emitter.
The best part of the @opExample
decorator is that it allows you to provide example values for the operation parameters and return types directly in the TypeSpec specification, and that these values are typed.
This enables code editors and IDEs to provide autocompletion and type-checking for the example values, making it easier to write and maintain the examples.
This also means TypeSpec forces you to keep examples up to date with the actual data structures, the lack of which is a common source of errors in API documentation.
To generate useful operation IDs in the OpenAPI document, we use the @operationId
decorator (opens in a new tab) from the @TypeSpec.OpenAPI
library.
Without this decorator, TypeSpec will still derive operation IDs from the operation names, but using the decorator allows us to provide more descriptive and meaningful operation IDs.
Keep in mind that specifying manual operation IDs can lead to duplicate IDs.
That concludes our tour of the BookStore
namespace. We've defined models for publications, orders, and errors, as well as interfaces for managing publications and orders.
We start by importing the necessary TypeSpec modules.
These modules are provided by the TypeSpec project, but are not part of the core TypeSpec language. They extend the capabilities of TypeSpec for specific use cases.
By writing three using
statements (opens in a new tab), we expose the contents of the Http
, OpenAPI
, and Versioning
modules to the current file. This allows us to use the functionality provided by these modules in our TypeSpec specification with less code.
Without these statements, we can still access the functionality of the modules by using the fully qualified names of the types and functions they provide.
For example, instead of writing @operationId
, we could write TypeSpec.OpenAPI.operationId
to access the operationId
decorator provided by the OpenAPI
module.
Next, we define the BookStore
namespace, which will contain all the models, interfaces, and operations related to the bookstore API. Namespaces are used to group related types and operations together, and avoid naming conflicts.
This is a file-level namespace (it has no code block delimiters – no {
and }
), which means it spans the entire file.
Taking a step back, we see that the BookStore
namespace is decorated with the @service
decorator (opens in a new tab) from the TypeSpec core library.
The @service
decorator marks the namespace as a service, and provides the API's title.
We decorate the BookStore
namespace with the @info
decorator (opens in a new tab) from the @TypeSpec.OpenAPI
library to provide information for the OpenAPI document's info
object.
TypeSpec's @versioned
decorator (opens in a new tab) from the @TypeSpec.Versioning
library specifies the versions of the API.
We define a single version, 1.0.0
, for the API, but you can define multiple versions if needed.
We add a @server
decorator to the BookStore
namespace, which specifies the base URL of the API server: http://127.0.0.1:4010
.
This is the default Prism (opens in a new tab) server URL here, but you can replace this with the actual base URL of your API server.
Finally, our BookStore
namespace is decorated with the @doc
decorator (opens in a new tab) from the TypeSpec core library, which provides a description of the API.
In the OpenAPI 3 emitter, this description will be used as the description
field in the OpenAPI document's info
object.
This brings us to our first model, PublicationBase
, which represents the base model for books and magazines in the store.
Here we see how models are defined in TypeSpec using the model
keyword, followed by the model name and a block of properties.
Property types are similar to those in OpenAPI, but with some nuances. For example, float32
is used instead of number
, and utcDateTime
is used for date-time values.
See the TypeSpec data types documentation (opens in a new tab) for more information on the available data types. We should also educate ourselves about how these data types are represented in the OpenAPI document (opens in a new tab).
The type
property is defined as an enum
(opens in a new tab) to represent the type of publication. This is a custom enum defined within the BookStore
namespace.
Next, we define constants, BookExample1
and BookExample2
, using the #{}
syntax. We'll use these examples to demonstrate the structure of the Book
model.
On their own, these constants are not part of the model, nor will they be emitted by the OpenAPI emitter. We have to pass them as values in the @example
decorator to include them in the OpenAPI document.
This is the Book
model, which extends the PublicationBase
model. We use the @example
decorator to provide an example value for the model.
The extends
keyword causes the Book
model to inherit properties from the PublicationBase
model, with the ability to add additional properties specific to books, or override existing properties.
Magazines are represented by the Magazine
model, which also extends the PublicationBase
model. We provide example values for the Magazine
model using the @example
decorator.
Note how the Magazine
model adds properties specific to magazines, such as issueNumber
and publisher
.
To represent both books and magazines in a single model, we define a Publication
union type (opens in a new tab). The Publication
model is a union of the Book
and Magazine
models, with a discriminator property type
to differentiate between the two.
The @discriminator
decorator specifies the property that will be used to determine the type of the publication.
The @oneOf
decorator (opens in a new tab) is specific to the OpenAPI 3 emitter, and indicates that the Publication
schema should reference the Book
and Magazine
schemas using the oneOf
keyword instead of allOf
.
The Order
model represents an order for publications in the store. It contains familiar properties much like those of the Publication
models, except for a reference to the Publication
model in the items
property.
The items
property is an array of publications, which can contain both books and magazines.
Moving on to operations, let's start with the Publications
interface, which wraps operations for managing publications in the store.
We decorate the Publications
interface with the @tag
decorator (opens in a new tab) from the standard library to specify the tag for the operations in the interface. Tags are used to group related operations in the OpenAPI document, and can be applied to interfaces, operations, and namespaces.
Since we are using the OpenAPI 3 emitter, the @tag
decorator will be used to generate the tags
field in the OpenAPI document.
The @route
decorator (opens in a new tab) provided by the @TypeSpec.Http
library specifies the path prefix for the operations in the Publications
interface.
In the Publications
interface, we define three operations: list
, get
, and create
.
Let's focus for a moment on what we don't see in the operation definitions.
Note how the op
keyword is optional when defining operations within an interface. The operations are defined directly within the interface block, without the need for the op
keyword.
The operations are also defined without an HTTP method, such as GET
or POST
. This is because the default HTTP method for an operation is determined by the operation's parameters.
If an operation contains a @body
parameter, it defaults to POST
. Any operation without a @body
parameter defaults to GET
.
The get
operation in the Publications
interface takes a string
parameter id
and returns a Publication
or an Error
.
Note how the @path
decorator is used to specify that the id
parameter is part of the path in the URL.
This operation will have the path /publications/{id}
in the OpenAPI document. TypeSpec will automatically generate the path parameter for the id
parameter.
Examples for operation parameters and return types are provided using the @opExample
decorator from the standard library. These examples will be included in the OpenAPI document to demonstrate the structure of the request and response payloads.
Note that this functionality, at the time of writing (with TypeSpec 0.58.1), is not yet fully implemented in the OpenAPI emitter.
The best part of the @opExample
decorator is that it allows you to provide example values for the operation parameters and return types directly in the TypeSpec specification, and that these values are typed.
This enables code editors and IDEs to provide autocompletion and type-checking for the example values, making it easier to write and maintain the examples.
This also means TypeSpec forces you to keep examples up to date with the actual data structures, the lack of which is a common source of errors in API documentation.
To generate useful operation IDs in the OpenAPI document, we use the @operationId
decorator (opens in a new tab) from the @TypeSpec.OpenAPI
library.
Without this decorator, TypeSpec will still derive operation IDs from the operation names, but using the decorator allows us to provide more descriptive and meaningful operation IDs.
Keep in mind that specifying manual operation IDs can lead to duplicate IDs.
That concludes our tour of the BookStore
namespace. We've defined models for publications, orders, and errors, as well as interfaces for managing publications and orders.
Step 5: Generate the OpenAPI Document
Now that we've written our TypeSpec specification, we can generate an OpenAPI document from it using the tsp
compiler.
Run the following command to generate an OpenAPI document:
The tsp compile
command creates a new directory called tsp-output
, then the @typespec/openapi3
emitter creates the directories @typespec/openapi3
within. If we were to use other emitters, such as protobuf, we would see @typespec/protobuf
directories instead.
Because we're using the versioning library, the OpenAPI document will be generated for the specified version of the API. In our case, the file generated by the OpenAPI 3 emitter will be named openapi.yaml
.
Step 6: View the Generated OpenAPI Document
Open the generated OpenAPI document in your text editor or a YAML viewer to see the API specification.
Let's scroll through the generated OpenAPI document to see how our TypeSpec specification was translated into an OpenAPI specification.
The OpenAPI document starts with the openapi
field, which specifies the version of the OpenAPI specification used in the document. In this case, it's version 3.0.0.
This version is determined by the emitter we used to generate the OpenAPI document. The @typespec/openapi3
emitter generates OpenAPI 3.0 documents.
The info
field contains metadata about the API, such as the title, terms of service, contact information, license, and description. This information is provided by the @service
and @info
decorators in the TypeSpec specification.
Let's take a closer look at the placeOrder
operation in the OpenAPI document. The operation is defined under the /orders
path and uses the POST
method.
Firstly, we see that the operation's operationId
is set to placeOrder
, which is the same as the @operationId
decorator in the TypeSpec specification.
The operation is tagged with the orders
tag, which is specified by the @tag
decorator in the TypeSpec specification. In this case, we tagged the Orders
interface with the orders
tag, instead of the individual operations.
The tags still apply to individual operations in the OpenAPI document, as seen here.
Instead of parameters, this operation uses a requestBody
field to specify the request payload. The @body
parameter in the TypeSpec specification corresponds to the requestBody
field in the OpenAPI document.
Of particular interest is the example
field in the requestBody
object. This field provides an example value for the request payload, demonstrating the structure of the data expected by the API.
The current implementation of the OpenAPI emitter supports the @opExample
decorator for operation examples, but does not yet support extended models or unions. This shows up in the generated OpenAPI document as empty objects in the items
array for the order
example.
Next, let's look at how the OpenAPI document represents our polymorphic Publication
model.
Because the Publication
model is a union of the Book
and Magazine
models, and we decorated this union with @oneOf
in TypeSpec, the OpenAPI document uses the oneOf
keyword to represent the union.
Unfortunately, as of version 0.58.1 of TypeSpec, the OpenAPI emitter also seems to fail to include the example values for the Publication
model in the OpenAPI document.
Likewise, the Book
schema's example is incomplete in the OpenAPI document. The emitter does not show the example values for the PublicationBase
properties, such as id
, title
, publishDate
, and price
.
In the Book
schema, we also see how the allOf
keyword is used to combine the properties of the PublicationBase
model with the additional properties of the Book
model.
Problems with examples aside, the OpenAPI document provides a clear representation of the API we defined in our TypeSpec specification.
Let's scroll through the generated OpenAPI document to see how our TypeSpec specification was translated into an OpenAPI specification.
The OpenAPI document starts with the openapi
field, which specifies the version of the OpenAPI specification used in the document. In this case, it's version 3.0.0.
This version is determined by the emitter we used to generate the OpenAPI document. The @typespec/openapi3
emitter generates OpenAPI 3.0 documents.
The info
field contains metadata about the API, such as the title, terms of service, contact information, license, and description. This information is provided by the @service
and @info
decorators in the TypeSpec specification.
Let's take a closer look at the placeOrder
operation in the OpenAPI document. The operation is defined under the /orders
path and uses the POST
method.
Firstly, we see that the operation's operationId
is set to placeOrder
, which is the same as the @operationId
decorator in the TypeSpec specification.
The operation is tagged with the orders
tag, which is specified by the @tag
decorator in the TypeSpec specification. In this case, we tagged the Orders
interface with the orders
tag, instead of the individual operations.
The tags still apply to individual operations in the OpenAPI document, as seen here.
Instead of parameters, this operation uses a requestBody
field to specify the request payload. The @body
parameter in the TypeSpec specification corresponds to the requestBody
field in the OpenAPI document.
Of particular interest is the example
field in the requestBody
object. This field provides an example value for the request payload, demonstrating the structure of the data expected by the API.
The current implementation of the OpenAPI emitter supports the @opExample
decorator for operation examples, but does not yet support extended models or unions. This shows up in the generated OpenAPI document as empty objects in the items
array for the order
example.
Next, let's look at how the OpenAPI document represents our polymorphic Publication
model.
Because the Publication
model is a union of the Book
and Magazine
models, and we decorated this union with @oneOf
in TypeSpec, the OpenAPI document uses the oneOf
keyword to represent the union.
Unfortunately, as of version 0.58.1 of TypeSpec, the OpenAPI emitter also seems to fail to include the example values for the Publication
model in the OpenAPI document.
Likewise, the Book
schema's example is incomplete in the OpenAPI document. The emitter does not show the example values for the PublicationBase
properties, such as id
, title
, publishDate
, and price
.
In the Book
schema, we also see how the allOf
keyword is used to combine the properties of the PublicationBase
model with the additional properties of the Book
model.
Problems with examples aside, the OpenAPI document provides a clear representation of the API we defined in our TypeSpec specification.