Background image

Building Speakeasy

How We Built It - Universal Typescript

Georges Haidar

Georges Haidar

January 20, 2024

Featured blog post image
Success Icon

Get Started

Try out our new TypeScript generation for yourself. Download our CLI to get started:


brew install speakeasy-api/homebrew-tap/speakeasy

In this blog post, we'll share the story behind why we decided to build v2 of our typescript generation. And talk through some of the critical design choices we made along the way. Whether you're a seasoned TypeScript developer or just starting out, we hope you'll find something valuable in this article.

Learning from V1

Let's start by talking about the open-source generators (opens in a new tab); there are a lot of them (11 for TypeScript currently). It'd be fair to ask, why do we need another one, and why would someone pay for it.

None of the existing generators are good enough to build SDKs for an enterprise API offering (without a fair amount of custom work). We're very glad that the open-source generators exist. They're great for experimentation and hobby projects, but they fall short of serving enterprises (which, to be fair, was never the intent of the OSS project).

We rolled out the first version of our TypeScript generator exactly 1 year ago. Our goal was to build an SDK generator that could serve enterprise use cases. Our initial requirements were:

  • Support for OpenAPI 3.0 & 3.1
  • Idiomatic TypeScript code
  • Runtime Type safety for both request and response payloads
  • Support for Node LTS
  • Support for retries & pagination
  • CI/CD Automation for building & publishing

We satisfied all of these requirements, but weren't ourselves satisfied with the result. We hadn't built something that was really great. It was certainly better than the open-source offerings available, but we found ourselves frustrated with a few aspects of the generator:

  1. Our approach to de/serialization had us in a straight jacket. We had decided to use classes with decorators. This approach had several limitations and made it difficult to maintain and extend our codebase. Specifically, it impeded our ability to build support for Union types. We needed a more flexible solution that would allow us to better support the evolving needs of our users without sacrificing runtime type safety.

export class Pet extends SpeakeasyBase {
@SpeakeasyMetadata({ data: "form, name=category;json=true" })
@Expose({ name: "category" })
@Type(() => Category)
category?: Category;
@SpeakeasyMetadata({ data: "form, name=id" })
@Expose({ name: "id" })
id?: number;
@SpeakeasyMetadata({ data: "form, name=name" })
@Expose({ name: "name" })
name: string;
@SpeakeasyMetadata({ data: "form, name=photoUrls" })
@Expose({ name: "photoUrls" })
photoUrls: string[];
/**
* pet status in the store
*/
@SpeakeasyMetadata({ data: "form, name=status" })
@Expose({ name: "status" })
status?: PetStatus;
@SpeakeasyMetadata({ data: "form, name=tags;json=true", elemType: Tag })
@Expose({ name: "tags" })
@Type(() => Tag)
tags?: Tag[];
}

  1. Our SDK generation was overly focused on server-side usage. Understandable, because this was the majority of our usage. But more and more JavaScript applications are being built where the line between server responsibility and client responsibility are blurred. In these applications, both rely on a common set of libraries. We wanted to cover this use case.

  2. Finally, the rise of AI APIs in the last year had created new and growing demand for TypeScript SDKs that could better handle long running data streams.

So three months ago, we embarked on the journey of building a new TypeScript generator to overcome these deficiencies.

How we designed our new generator

A theme that we're going to keep coming back to is, "Use the platform". We're building a TypeScript generator, so we use TypeScript primitives wherever possible. We're building a generator for OpenAPI, so we use OpenAPI primitives wherever possible.

Seems straightforward, but sometimes it's easy to get caught up in the excitement of building something new and forget to take advantage of the tools that are already available to us.

Rebuilding Type Safety with Zod

Validation is really important when you talk about APIs. By making your API’s inputs explicit, developers can debug in their IDE as they write the application code, sparing them the frustration of having to compare constructed data object to API docs to see where mistakes occurred. This is even more important in the world of JavaScript, where users will pass you anything, and your types don't exist at runtime.

As we mentioned, our first attempt worked at plugging the hole of runtime type safety, but it had downsides. Adding support for more complex types was overly difficult.

In our second attempt, we turned to Zod: "A TypeScript-first schema validation library that allows you to define schemas from a simple string to a complex nested object."

Our TypeScript generator creates Zod schemas for all the request and response objects in a users OpenAPI spec:


export namespace ProductInput$ {
export type Inbound = {
name: string;
price: number;
};
export const inboundSchema: z.ZodType<ProductInput, z.ZodTypeDef, Inbound> = z
.object({
name: z.string(),
price: z.number().int(),
})
.transform((v) => {
return {
name: v.name,
price: v.price,
};
});
export type Outbound = {
name: string;
price: number;
};
export const outboundSchema: z.ZodType<Outbound, z.ZodTypeDef, ProductInput> = z
.object({
name: z.string(),
price: z.number().int(),
})
.transform((v) => {
return {
name: v.name,
price: v.price,
};
});
}

We then use these schemas to validate the request and response payloads at runtime:


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"
// }
// ]

Our validation also goes beyond user input, by validating the server's responses. We will guarantee that what your API is providing to users is correct, as per your spec. And if it's not, we fail loudly. There's no hidden errors for users to parse through.

Validation Tradeoffs

It's worth acknowledging that there are tradeoffs to using Zod. The biggest one is having a 3rd party dependency in the library. We've tried hard to keep our generated libraries free of them because of the security risks they pose. However, validation is a critical feature, and Zod doesn't pull in any additional dependencies, so we felt it was well worth it.

Additionally, we've encountered a couple of truly enormous OpenAPI specs that have resulted in huge SDKs. These can suffer some performance regressions from having to type-check all the way through. It's an edge case, and one that we're working on some heuristics to mitigate, but it's worth noting.

Going TypeScript Native for Client Support

Most SDKs you encounter will live in repos like acme-sdk-node or acme-sdk-front-end, but these qualifiers don't need to exist anymore. The primitives needed to run a feature-rich Typescript SDK across all the major runtimes are available. And that what we set out to build

The biggest changes that we needed to make were dropping our dependency on Axios and moving to the native fetch API. We now have a single codebase that can be used in any environment, including Node.js, browsers, and serverless functions.

Other changes we needed to make to better support client-side usage were:

  • Enabling tree-shaking - we decoupled the SDKs' modules wherever possible. If SDKs are subdivided into namespaces, such as sdk.comments.create(...), 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.

Universality Tradeoffs

If you set out to build an SDK per runtime, like an SDK for node, an SDK for bun, you will be able to leverage more native APIs that in some instances could perform better than the primitive APIs we use. For example, Node's stream library (opens in a new tab) is a little bit more performant than the web streams one, but we think the trade off that we made is valuable in the long run and better at enterprise scale.

It's cognitively simpler to distribute and talk about one SDK. It allows you to manage one set of documentation, and maintain one education path for users. That's invaluable to an org operating at scale.

Adding Support for Data Streams

The last major feature we wanted to add was support for data streams. When we talk about data streams, we're talking about support for two different things, both of which are important for AI APIs. The first is traditional file uploads and downloads. Previously, we had supported this where the entire file was loaded into memory before being sent to the server. This is fine for small files, but isn't workable for large ones.

We shifted our file support to use the Blob() and File() APIs, so that files could be sent to/from APIs via a data stream. We base that on whether your OpenAPI spec marks a certain field as binary data. If it does we'll automatically generate a type for that field such that your users can pass a blob-like object or an entire byte array to upload. On the response side, if we know that the response is binary data, then users will be given a readable stream to consume.

Finally, there's another type of streaming that we've built support for: server-sent events (SSEs). SSEs have gained popularity recently as a way to stream data from the server to the client. They're a great fit for AI APIs, where you might be streaming the results of a long running job.

We've built a straightforward implementation that doesn't require any proprietary extensions in your OpenAPI spec. Just mark the response as a text/event-stream and we'll automatically generate a type for it. We'll also generate a stream option on the request object that will return an async iterable that you can use to consume the stream:


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();

Summary

So that's it. We're really proud of the new TypeScript generator that we've built. We think it's better than any other TypeScript generation that's out there, and is on par with even the best hand-written SDKs. We're interested in hearing people's feedback, so please give it a go yourself. Join our public slack (opens in a new tab) and let us know what you think!

CTA background illustrations

Speakeasy Changelog

Subscribe to stay up-to-date on Speakeasy news and feature releases.