Product Updates
Introducing Universal TypeScript: A TS SDK your users will love
Georges Haidar
December 1, 2023
The best SDKs for the biggest language community
Today, we're introducing our updated TypeScript code generation target that will power the next wave of TypeScript SDKs built on top of OpenAPI. Our new code generator takes full advantage of TypeScript's type system, native Fetch APIs and the amazing data validation and transformation library Zod (opens in a new tab) to deliver feature rich SDKs that run anywhere modern JavaScript can run.
There's a lot to unpack so, before going any further, here are the headline features that come with TypeScript SDKs generated using Speakeasy:
- Compatibility with the browser & server
- Support for popular JavaScript runtimes including Node.js, Bun, Deno, React Native
- User input and server response validation with Zod
- Support for polymorphic types, also known as unions or
oneOf
in OpenAPI. - Support for multipart streaming upload
To get started, all you need is an OpenAPI spec. Simply install the speakeasy CLI, and start generating:
brew install speakeasy-api/homebrew-tap/speakeasy
speakeasy generate sdk -s openapi.yaml --output ./sdk -l typescript
Breaking changes
If you are currently using Speakeasy for TypeScript generation, we've listed the breaking changes in the later sections of this post as well as instructions on using the new generator.
New features
Works in the browser, Node.js and other modern JS runtimes
One key decision we took when designing for new TypeScript SDKs was to stick as close to modern and ubiquitous web standards as possible. This included switching from Axios to 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
. Previously, SDKs leaked some of the Axios API and it meant that users needed to be aware of it which was undesirable. Making this move also ensures that your SDKs will work seamlessly on both the server & browser. We also observed frameworks like Next.js
which specifically augment the fetch API to enable caching of HTTP responses within React Server Components. That is now unlocked with new SDKs.
In addition to browser compatibility, 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. We’re working to expand our automated testing to cover Deno and React Native.
Wherever and however your users are building, they will be able to use your SDK.
Tree-shaking-ly good
Our new SDKs contain fewer internal couplings between modules. This means users that are bundling them into client-side apps can take advantage of better 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" as we call them, 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 bundlersasync function run() { const payments = new PaymentsSDK({ authKey: "" }); const result = await payments.list(); console.log(result);}run();
We also 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.
Support for server-sent events
We're really excited to share that TypeScript SDKs now support streaming events from your API using server-sent events (opens in a new tab) (SSE). SSE is a feature of the web that has been around for quite some time and has seen renewed popularity in the AI space. It's the technology that's powering some of your favourite AI / LLM chat-based user interfaces.
Here's an example of working with a chat completion stream in Node.js:
import { SDK } from "@speakeasy/super-sdk";async function run() { const sdk = new SDK(); const result = await sdk.chat({ stream: true, messages: [{ role: "user", text: "Tell me three interesting facts about Norwegian Forest cats." }] }); if (!result.chatStream) { throw new Error("expected completion stream"); } for await (const event of result.chatStream) { process.stdout.write(event.data.content); } // 👆 gradually prints the chat response to the terminal}run();
We wanted to make sure the experience is ergonomic and found that exposing an async iterable (opens in a new tab) which can be looped over was our favourite solution. This will work the same way in the browser and other JavaScript runtimes!
One of the challenges, we've had to tackle when working on this feature was figuring out how to model these APIs within OpenAPI and we're proud to share that we've developed a proposed specification that is free from propietary extensions. It's vanilla OpenAPI and you can start describing your SSE endpoints with it today then generate SDKs with Speakeasy. As more and more chat-based products emerge, we want to ensure that the APIs and SDKs powering them are free from unnecessary vendor lock-in and instead move towards a common approach to describing them.
Runtime validation powered by Zod (opens in a new tab)
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 new TypeScript SDKs solve this issue neatly by modelling all the data at the boundaries using Zod schemas. 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.
Unions are here
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 Cat | Dog
. We want to give a big shout out to Zod for helping us deliver this feature!
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();
Support for data streaming
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 (opens in a new tab) or Blob (opens in a new tab) and pass it to SDKs.
For response streaming, SDKs expose a ReadableStream (opens in a new tab), a part of the Streams API (opens in a new tab) 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();
Support for arbitrary-precision decimals powered by decimal.js (opens in a new tab)
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.
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))});
Support for big integers using the native BigInt
type
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 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")});
Breaking changes
ES2020 and Node.js v18+
In order to deliver our leanest TypeScript SDKs yet, we set out to avoid unnecessary third-party libraries, polyfills and transpilation which could inflate JavaScript bundles. Based on browser and backend usage statistics, we decided to create a support policy which targets JavaScript features that have been available for at least 3 years. Additionally, when it comes to Node.js in particular, we'll be supporting the current LTS releases. At the time of writing, this is version 18 (source (opens in a new tab)).
Moving from Axios to fetch
Our previous TypeScript SDK generator used Axios (opens in a new tab) as the underlying HTTP client. SDKs were also exposing Axios APIs to users establishing an unwanted expectation that they are familiar with this library and understand how to configure custom clients and requests. Fortunately, the Fetch API (opens in a new tab) is a standard web platform API and has become ubiquitous across all runtimes, from browsers to React Native to Bun/Deno/Node.js. Switching to fetch
means SDKs no longer pull in an unnecessary third-party dependency and leverage standard platform features.
Changes in SDK file structure
In previous versions of our SDKs, various functionality such as model classes and types were nest in the package's directory structure under a dist/
folder. While modern editors with language server support take the burden out of typing out imports, it was still unpleasant to see the build folder present in import paths. Many of our existing users commented as much and we fully agree so we've updated how we package SDKs into NPM modules and eliminated this folder from appearing in import paths. This is a breaking change but one that we think SDK owners and users will appreciate.
We've also reorganised various supporting files in the SDK and moved away from the internal/
package to lib/
. We do not believe this is going to affect end-users of SDKs but, since it's a breaking change, we're listing it here for completeness.
Next steps
If you are using Speakeasy to generate your TypeScript SDK for the first time, then you'll automatically be using our new generator.
For existing Speakeasy customers with TypeScript SDKs, we've introduced a new field that you can add in your gen.yaml
file, called templateVersion
, to opt-in to the new generator:
configVersion: 1.0.0# Rest of gen.yaml omitted for brevitytypescript:+ templateVersion: v2
If you are using our GitHub Action then, after committing that change, the next run will generate a refreshed SDK. speakeasy
CLI users can rerun the generate
command which will pick up the flag and regenerate the new SDK.
Building on good foundations
We're really excited to provide users with an awesome experience using machine-generated SDKs. There's often a trade-off that product engineers and API owners consider when relying on code generators versus hand-building SDKs and the quality of the code and public interface they produce. We believe that our refreshed TypeScript SDK generator has baked in a lot of good ideas that ultimately result in a great developer experience, one that increasingly feels like working with a carefully curated TypeScript SDK. We now have the foundation to build even more exciting features like support for Server-sent Events and we're looking forward to taking more of the pain away from shipping awesome DX for your products.
If you do try out Speakeasy and our TypeScript SDKs then we'd love to get your feedback about your experience, new ideas or feature requests.
Happy hacking!