Create TypeScript SDKs from OpenAPI / Swagger

SDK Overview

Speakeasy's TypeScript SDK creation is designed to build idiomatic TypeScript libraries, using standard web platform features.

The SDK is strongly typed, makes minimal use of third party modules, and is straight-forward to to debug. It should feel familiar to TypeScript developers when they use SDKs we generate. We make opinionated choices in some places, but we do so in a thoughtful and deliberate way.

The core features of the TypeScript SDK include:

  • Compatibility with vanilla JavaScript projects since the SDK's consumption is through .d.ts (TypeScript type definitions) and .js files.
  • Usable on the server and in the browser
  • Use of the fetch, ReadableStream and async iterable APIs for compatibility with all the popular JavaScript runtimes:
    • Node.js
    • Deno
    • Bun
  • Support for streaming requests and responses
  • Authentication support for OAuth flows as well as support for standard security mechanisms (HTTP Basic, application tokens, etc.)
  • Optional pagination support for supported APIs
  • Optional support for retries in every operation
  • Complex number types including big integers and decimal
  • Date and date/time types using RFC3339 date formats
  • Custom type enums using strings and ints
  • Union types and combined types

TypeScript Package Structure

lib-structure.yaml

|-- src # Root for all library source files
| β””-- lib # The core internals of the SDK
| | β””-- ...
| | β””-- http.ts
| | β””-- sdks.ts
| | β””-- http.ts
| | β””-- ...
| |-- sdk
| | β””-- models
| | | β””-- errors
| | | β””-- operations # Namespace for additional helper types used during marshaling and unmarshalling
| | | | β””-- ...
| | | β””-- shared # Namespace for the SDK's shared models, including those from OpenAPI components
| | | | β””-- ...
| | β””-- types # Custom types support used in the SDK
| | | β””-- ...
| | β””-- index.ts
| | β””-- sdk.ts
| | β””-- ..
| β””-- index.ts
|-- docs # Markdown files for the SDK's documentation
| β””- ...
β””-- ...

Runtime Environment Requirements

The SDK targets ES2018, ensuring compatibility with a wide range of JavaScript runtimes that support this version. Key features required by the SDK include:

Runtime environments that are explicitly supported are:

  • Evergreen browsers: Chrome, Safari, Edge, Firefox
  • Node.js active and maintenance LTS releases
    • Currently, this is v18 and v20
  • Bun v1 and above
  • Deno v1.39 - Note that Deno does not currently have native support for streaming file uploads backed by the filesystem (issue link (opens in a new tab))
Info Icon

Note

For teams interested in working directly with the SDK's source files our SDK leverages TypeScript v5 features. This means that to directly consume these source files, your environment should support TypeScript version 5 or higher. However, this requirement is specific only to scenarios where direct access to the source is necessary.

TypeScript HTTP Client

TypeScript SDKs stick as close to modern and ubiquitous web standards as possible. We use the fetch() API as our HTTP client. This API includes all the necessary building blocks to make HTTP requests: fetch, Request, Response, Headers, FormData, File and Blob.

The standard nature of this SDK means it will work in modern JavaScript runtimes. This includes: Node.js, Deno, Bun, React Native. We’ve already been able to run our extensive suite to confirm that new SDKs work in Node.js, Bun and browsers.

Type System

Primitive Types

Where possible the TypeScript SDK preferences primitive types such as string, number and boolean. However, in the case of arbitrary-precision decimals, a third-party library is used since there isn't a native decimal type. Using decimal types is crucial in certain applications such as code manipulating monetary amounts and in situations where overflow, underflow, or truncation caused by precision loss can lead to significant incidents.

To describe a decimal type in OpenAPI, you can use the format: decimal keyword. The SDK will take care of serializing and deserializing decimal values under the hood using the decimal.js (opens in a new tab) library.


import { SDK } from "@speakeasy/super-sdk";
import { Decimal } from "@speakeasy/super-sdk/types";
const sdk = new SDK();
const result = await sdk.payments.create({
amount: new Decimal(0.1).add(new Decimal(0.2))
});

Similar to decimal types, there are numbers too large to be represented using JavaScript’s Number type. For this reason, we’ve introduced support for native BigInt values.

In an OpenAPI schema, fields that are big integers can be modelled as strings with format: bigint.


import { SDK } from "@speakeasy/super-sdk";
const sdk = new SDK();
const result = await sdk.doTheThing({
value: 67_818_454n,
value: BigInt("340656901")
});

Generated Types

The TypeScript SDK generates a type for each request and response models as well as for each shared model in your OpenAPI schema. Each model is backed by a Zod (opens in a new tab) schema which is used to validate the objects at runtime.

Info Icon

Note

It's important to that data validation is run on both user input when calling an SDK method AND on the subsequent response data from the server. If servers are not returning data that matches the OpenAPI spec then validation errors are thrown at runtime.

Below is a complete example of a shared model created by the TypeScript generator:

Public type

The type DrinkOrder represents the public type that the model file exports. This is what users of the SDK will work with in their code.

drinkorder.ts

import { Decimal as Decimal$ } from "../../types";
import { Customer, Customer$ } from "./customer";
import { DrinkType, DrinkType$ } from "./drinktype";
import { z } from "zod";
export type DrinkOrder = {
id: string;
type: DrinkType;
customer: Customer;
totalCost: Decimal$ | number;
createdAt: Date;
};
/** @internal */
export namespace DrinkOrder$ {
export type Inbound = {
id: string;
type: DrinkType;
customer: Customer$.Inbound;
total_cost: string;
created_at: string;
};
export const inboundSchema: z.ZodType<DrinkOrder, z.ZodTypeDef, Inbound> = z
.object({
id: z.string(),
type: DrinkType$,
customer: Customer$.inboundSchema,
total_cost: z.string().transform((v) => new Decimal$(v)),
created_at: z
.string()
.datetime({ offset: true })
.transform((v) => new Date(v)),
})
.transform((v) => {
return {
id: v.id,
type: v.type,
customer: v.customer,
totalCost: v.total_cost,
createdAt: v.created_at,
};
});
export type Outbound = {
id: string;
type: DrinkType;
customer: Customer$.Outbound;
total_cost: string;
created_at: string;
};
export const outboundSchema: z.ZodType<Outbound, z.ZodTypeDef, DrinkOrder> = z
.object({
id: z.string(),
type: DrinkType$,
customer: Customer$.outboundSchema,
totalCost: z
.union([z.instanceof(Decimal$), z.number()])
.transform((v) => `${v}`),
createdAt: z.date().transform((v) => v.toISOString()),
})
.transform((v) => {
return {
id: v.id,
type: v.type,
customer: v.customer,
total_cost: v.totalCost,
created_at: v.createdAt,
};
});
}

Internal types

A special namespace accompanies every model and contains the types and schemas for the model that represent inbound and outbound data.

This namespace, including types and values within it, is not intended for use outside the SDK and as such it is marked as @internal.

drinkorder.ts

import { Decimal as Decimal$ } from "../../types";
import { Customer, Customer$ } from "./customer";
import { DrinkType, DrinkType$ } from "./drinktype";
import { z } from "zod";
export type DrinkOrder = {
id: string;
type: DrinkType;
customer: Customer;
totalCost: Decimal$ | number;
createdAt: Date;
};
/** @internal */
export namespace DrinkOrder$ {
export type Inbound = {
id: string;
type: DrinkType;
customer: Customer$.Inbound;
total_cost: string;
created_at: string;
};
export const inboundSchema: z.ZodType<DrinkOrder, z.ZodTypeDef, Inbound> = z
.object({
id: z.string(),
type: DrinkType$,
customer: Customer$.inboundSchema,
total_cost: z.string().transform((v) => new Decimal$(v)),
created_at: z
.string()
.datetime({ offset: true })
.transform((v) => new Date(v)),
})
.transform((v) => {
return {
id: v.id,
type: v.type,
customer: v.customer,
totalCost: v.total_cost,
createdAt: v.created_at,
};
});
export type Outbound = {
id: string;
type: DrinkType;
customer: Customer$.Outbound;
total_cost: string;
created_at: string;
};
export const outboundSchema: z.ZodType<Outbound, z.ZodTypeDef, DrinkOrder> = z
.object({
id: z.string(),
type: DrinkType$,
customer: Customer$.outboundSchema,
totalCost: z
.union([z.instanceof(Decimal$), z.number()])
.transform((v) => `${v}`),
createdAt: z.date().transform((v) => v.toISOString()),
})
.transform((v) => {
return {
id: v.id,
type: v.type,
customer: v.customer,
total_cost: v.totalCost,
created_at: v.createdAt,
};
});
}

The inbound representation of a model defines the shape of the data that is received from a server. It is validated and deserialized into the public type above.

drinkorder.ts

import { Decimal as Decimal$ } from "../../types";
import { Customer, Customer$ } from "./customer";
import { DrinkType, DrinkType$ } from "./drinktype";
import { z } from "zod";
export type DrinkOrder = {
id: string;
type: DrinkType;
customer: Customer;
totalCost: Decimal$ | number;
createdAt: Date;
};
/** @internal */
export namespace DrinkOrder$ {
export type Inbound = {
id: string;
type: DrinkType;
customer: Customer$.Inbound;
total_cost: string;
created_at: string;
};
export const inboundSchema: z.ZodType<DrinkOrder, z.ZodTypeDef, Inbound> = z
.object({
id: z.string(),
type: DrinkType$,
customer: Customer$.inboundSchema,
total_cost: z.string().transform((v) => new Decimal$(v)),
created_at: z
.string()
.datetime({ offset: true })
.transform((v) => new Date(v)),
})
.transform((v) => {
return {
id: v.id,
type: v.type,
customer: v.customer,
totalCost: v.total_cost,
createdAt: v.created_at,
};
});
export type Outbound = {
id: string;
type: DrinkType;
customer: Customer$.Outbound;
total_cost: string;
created_at: string;
};
export const outboundSchema: z.ZodType<Outbound, z.ZodTypeDef, DrinkOrder> = z
.object({
id: z.string(),
type: DrinkType$,
customer: Customer$.outboundSchema,
totalCost: z
.union([z.instanceof(Decimal$), z.number()])
.transform((v) => `${v}`),
createdAt: z.date().transform((v) => v.toISOString()),
})
.transform((v) => {
return {
id: v.id,
type: v.type,
customer: v.customer,
total_cost: v.totalCost,
created_at: v.createdAt,
};
});
}

The outbound representation of a model defines the shape of the data that is sent to a server. A user provides a value that satisfies the public type above and the outbound schema serializes it into what the server expects.

drinkorder.ts

import { Decimal as Decimal$ } from "../../types";
import { Customer, Customer$ } from "./customer";
import { DrinkType, DrinkType$ } from "./drinktype";
import { z } from "zod";
export type DrinkOrder = {
id: string;
type: DrinkType;
customer: Customer;
totalCost: Decimal$ | number;
createdAt: Date;
};
/** @internal */
export namespace DrinkOrder$ {
export type Inbound = {
id: string;
type: DrinkType;
customer: Customer$.Inbound;
total_cost: string;
created_at: string;
};
export const inboundSchema: z.ZodType<DrinkOrder, z.ZodTypeDef, Inbound> = z
.object({
id: z.string(),
type: DrinkType$,
customer: Customer$.inboundSchema,
total_cost: z.string().transform((v) => new Decimal$(v)),
created_at: z
.string()
.datetime({ offset: true })
.transform((v) => new Date(v)),
})
.transform((v) => {
return {
id: v.id,
type: v.type,
customer: v.customer,
totalCost: v.total_cost,
createdAt: v.created_at,
};
});
export type Outbound = {
id: string;
type: DrinkType;
customer: Customer$.Outbound;
total_cost: string;
created_at: string;
};
export const outboundSchema: z.ZodType<Outbound, z.ZodTypeDef, DrinkOrder> = z
.object({
id: z.string(),
type: DrinkType$,
customer: Customer$.outboundSchema,
totalCost: z
.union([z.instanceof(Decimal$), z.number()])
.transform((v) => `${v}`),
createdAt: z.date().transform((v) => v.toISOString()),
})
.transform((v) => {
return {
id: v.id,
type: v.type,
customer: v.customer,
total_cost: v.totalCost,
created_at: v.createdAt,
};
});
}

All generated models have this overall structure. By pinning the types with runtime validation using, we give users stronger guarantees that the SDK types they work with at development time continue to be valid at runtime otherwise we throw exceptions i.e. fail loudly.

drinkorder.ts

import { Decimal as Decimal$ } from "../../types";
import { Customer, Customer$ } from "./customer";
import { DrinkType, DrinkType$ } from "./drinktype";
import { z } from "zod";
export type DrinkOrder = {
id: string;
type: DrinkType;
customer: Customer;
totalCost: Decimal$ | number;
createdAt: Date;
};
/** @internal */
export namespace DrinkOrder$ {
export type Inbound = {
id: string;
type: DrinkType;
customer: Customer$.Inbound;
total_cost: string;
created_at: string;
};
export const inboundSchema: z.ZodType<DrinkOrder, z.ZodTypeDef, Inbound> = z
.object({
id: z.string(),
type: DrinkType$,
customer: Customer$.inboundSchema,
total_cost: z.string().transform((v) => new Decimal$(v)),
created_at: z
.string()
.datetime({ offset: true })
.transform((v) => new Date(v)),
})
.transform((v) => {
return {
id: v.id,
type: v.type,
customer: v.customer,
totalCost: v.total_cost,
createdAt: v.created_at,
};
});
export type Outbound = {
id: string;
type: DrinkType;
customer: Customer$.Outbound;
total_cost: string;
created_at: string;
};
export const outboundSchema: z.ZodType<Outbound, z.ZodTypeDef, DrinkOrder> = z
.object({
id: z.string(),
type: DrinkType$,
customer: Customer$.outboundSchema,
totalCost: z
.union([z.instanceof(Decimal$), z.number()])
.transform((v) => `${v}`),
createdAt: z.date().transform((v) => v.toISOString()),
})
.transform((v) => {
return {
id: v.id,
type: v.type,
customer: v.customer,
total_cost: v.totalCost,
created_at: v.createdAt,
};
});
}

Public type

The type DrinkOrder represents the public type that the model file exports. This is what users of the SDK will work with in their code.

Internal types

A special namespace accompanies every model and contains the types and schemas for the model that represent inbound and outbound data.

This namespace, including types and values within it, is not intended for use outside the SDK and as such it is marked as @internal.

The inbound representation of a model defines the shape of the data that is received from a server. It is validated and deserialized into the public type above.

The outbound representation of a model defines the shape of the data that is sent to a server. A user provides a value that satisfies the public type above and the outbound schema serializes it into what the server expects.

All generated models have this overall structure. By pinning the types with runtime validation using, we give users stronger guarantees that the SDK types they work with at development time continue to be valid at runtime otherwise we throw exceptions i.e. fail loudly.

drinkorder.ts

import { Decimal as Decimal$ } from "../../types";
import { Customer, Customer$ } from "./customer";
import { DrinkType, DrinkType$ } from "./drinktype";
import { z } from "zod";
export type DrinkOrder = {
id: string;
type: DrinkType;
customer: Customer;
totalCost: Decimal$ | number;
createdAt: Date;
};
/** @internal */
export namespace DrinkOrder$ {
export type Inbound = {
id: string;
type: DrinkType;
customer: Customer$.Inbound;
total_cost: string;
created_at: string;
};
export const inboundSchema: z.ZodType<DrinkOrder, z.ZodTypeDef, Inbound> = z
.object({
id: z.string(),
type: DrinkType$,
customer: Customer$.inboundSchema,
total_cost: z.string().transform((v) => new Decimal$(v)),
created_at: z
.string()
.datetime({ offset: true })
.transform((v) => new Date(v)),
})
.transform((v) => {
return {
id: v.id,
type: v.type,
customer: v.customer,
totalCost: v.total_cost,
createdAt: v.created_at,
};
});
export type Outbound = {
id: string;
type: DrinkType;
customer: Customer$.Outbound;
total_cost: string;
created_at: string;
};
export const outboundSchema: z.ZodType<Outbound, z.ZodTypeDef, DrinkOrder> = z
.object({
id: z.string(),
type: DrinkType$,
customer: Customer$.outboundSchema,
totalCost: z
.union([z.instanceof(Decimal$), z.number()])
.transform((v) => `${v}`),
createdAt: z.date().transform((v) => v.toISOString()),
})
.transform((v) => {
return {
id: v.id,
type: v.type,
customer: v.customer,
total_cost: v.totalCost,
created_at: v.createdAt,
};
});
}

Union Types

Support for polymorphic types is critical to most production applications. In OpenAPI, these types are defined using the oneOf keyword. We represent these using TypeScript's union notation e.g. Cat | Dog.


import { SDK } from "@speakeasy/super-sdk";
async function run() {
const sdk = new SDK();
const pet = await sdk.fetchMyPet();
switch (pet.type) {
case "cat":
console.log(pet.litterType);
break;
case "dog":
console.log(pet.favoriteToy);
break;
default:
// Ensures exhaustive switch statements in TypeScript
pet satisfies never;
throw new Error(`Unidentified pet type: ${pet.type}`)
}
}
run();

Type Safety

TypeScript provides static type safety to give you greater confidence in the code your shipping. However, TypeScript has limited support to protect from opaque data at the boundaries of your programs. User input and server data coming across the network can circumvent static typing if not correctly modelled. This usually means marking this data as unknown and exhaustively sanitizing it.

Our TypeScript SDKs solve this issue neatly by modelling all the data at the boundaries using Zod schemas (opens in a new tab). That ensures that everything coming from users and servers will work as intended, or fail loudly with clear validation errors. This is even more impactful for the vanilla JavaScript developers using your SDK.


import { SDK } from "@speakeasy/super-sdk";
async function run() {
const sdk = new SDK();
const result = await sdk.products.create({
name: "Fancy pants",
price: "ummm"
});
}
run();
// 🚨 Throws
//
// ZodError: [
// {
// "code": "invalid_type",
// "expected": "number",
// "received": "string",
// "path": [
// "price"
// ],
// "message": "Expected number, received string"
// }
// ]

While validating user input is considered table stakes for SDKs, it’s especially useful to validate server data given the information we have in your OpenAPI spec. This can help detect drift between schema and server and prevent certain runtime issues such as missing response fields or sending incorrect data types.

Tree Shaking

Speakeasy created Typescript SDKs contain few internal couplings between modules. This means users that are bundling them into client-side apps can take advantage of tree-shaking performance when working with "deep" SDKs. These are SDKs that are subdivided into namespaces such as sdk.comments.create(...) and sdk.posts.get(...). Importing the top-level SDK will pull in the entire SDK into a client-side bundle even if a small subset of functionality was needed.

It's now possible to import the exact namespaces, or "sub-SDKs", and tree-shake the rest of the SDK away at build time.


import { PaymentsSDK } from "@speakeasy/super-sdk/sdk/payments";
// πŸ‘† Only code needed by this SDK is pulled in by bundlers
async function run() {
const payments = new PaymentsSDK({ authKey: "" });
const result = await payments.list();
console.log(result);
}
run();

We benchmarked whether there would be benefits in allowing users to import individual SDK operations but from our testing it seems that this only yielded marginal reduction in bundled code versus importing sub-SDKs. It's highly dependent on how operations are grouped and the depth and breadth of an SDK as defined in the OpenAPI spec. If you think your SDK users could greatly benefit from exporting individual operations then please reach out to us and we can re-evaluate this feature.

Streaming Support

Support for streaming is critical for applications that need to send or receive large amounts of data between client and server without first buffering the data into memory, potentially exhausting this system resource. Uploading a very large file is one use case where streaming can be useful.

As an example, in Node.js v20, streaming a large file to a server using an SDK is only a handful of lines:


import { openAsBlob } from "node:fs";
import { SDK } from "@speakeasy/super-sdk";
async function run() {
const sdk = new SDK();
const fileHandle = await openAsBlob("./src/sample.txt");
const result = await sdk.upload({ file: fileHandle });
console.log(result);
}
run();

On the browser, users would typically select files using <input type="file"> and the SDK call is identical to the sample code above.

Other JavaScript runtimes may have similar native APIs to obtain a web-standards File or Blob and pass it to SDKs.

For response streaming, SDKs expose a ReadableStream, a part of the Streams API web standard.


import fs from "node:fs";
import { Writable } from "node:stream";
import { SDK } from "@speakeasy/super-sdk";
async function run() {
const sdk = new SDK();
const result = await sdk.usageReports.download("UR123");
const destination = Writable.toWeb(
fs.createWriteStream("./report.csv")
);
await result.data.pipeTo(destination);
}
run();

Server-sent events

TypeScript SDKs support streaming of server-sent events by exposing an async iterables. Unlike the native EventSource API, SDKs can create streams using GET or POST requests, as well as other methods, that can pass custom headers and request bodies.


import { SDK } from "@speakeasy/super-sdk";
async function run() {
const sdk = new SDK();
const result = await sdk.completions.chat({
messages: [
{
role: 'user',
content: "What is the fastest bird that is common in North America?",
},
],
});
if (result.chatStream == null) {
throw new Error("failed to create stream: received null value");
}
for await (const event of res.chatStream) {
process.stdout.write(event.data.content)
}
}
run();

For more information on how to model this API in your OpenAPI document, check this page.

Parameters

If configured we will generate methods with parameters for each of the parameters defined in the OpenAPI document, as long as the number of parameters is less than or equal to the configured maxMethodParams value in the gen.yaml file.

If the number of parameters exceeds the configured maxMethodParams value or this is set to 0 then a request object is generated for the method instead that allows for all parameters to be passed in a single object.

Errors

Following TypeScript best practices, all operation methods in the SDK will return a response object and an error. Callers should always check for the presence of the error. The object used for errors is configurable per request. Any error response may return a custom error object. A generic error will be provided when any sort of communication failure is detected during an operation.

Here's an example of custom error handling in a theoretical SDK:


import { Speakeasy } from "@speakeasy/bar";
import * as errors from "@speakeasy/bar/sdk/models/errors";
async function run() {
const sdk = new Speakeasy({
apiKey: "<YOUR_API_KEY_HERE>",
});
const res = await sdk.bar.getDrink().catch((err) => {
if (err instanceof errors.FailResponse) {
console.error(err); // handle exception
return null;
} else {
throw err;
}
});
if (res?.statusCode !== 200) {
throw new Error("Unexpected status code: " + res?.statusCode || "-");
}
// handle response
}
run();

The SDK also includes a SDKValidationError that will make it easier to debug validation errors particularly when the server sends unexpected data. Instead of throwing a ZodError back at SDK users and that didn't let you see what the underlying raw data was that failed validation. SDKValidationError solves this problem and provides a way to pretty-print the validation errors for a more pleasant debugging experience.

Debugging support

The typescript SDKs support a new response format that will include the native Request / Response objects that were used in an SDK method call. This can be enabled by setting the responseFormat config in your gen.yaml file to envelope-http.


const sdk = new SDK();
const { users, httpMeta } = await sdk.users.list();
// πŸ‘†
const { request, response } = httpMeta;
console.group("Request completed")
console.log("Endpoint:", request.method, request.url)
console.log("Status", response.status)
console.log("Content type", response.headers.get("content-type"))
console.groupEnd()

The httpMeta property will also be available on any error class that relates to HTTP requests. This includes the built-in SDKError class and any custom error classes that you have defined in your spec.

User Agent Strings

The Typescript SDK will include a user agent (opens in a new tab) string in all requests. This can be leveraged for tracking SDK usage amongst broader API usage. The format is as follows:


speakeasy-sdk/typescript {{SDKVersion}} {{GenVersion}} {{DocVersion}} {{PackageName}}

Where

  • SDKVersion is the version of the SDK, defined in gen.yaml and released
  • GenVersion is the version of the Speakeasy generator
  • DocVersion is the version of the OpenAPI document
  • PackageName is the name of the package defined in gen.yaml