Skip to content

Creating Custom Types in TypeScript with Indexed Access Types, Const Assertions, and Satisfies

This article was written over 18 months ago and may contain information that is out of date. Some content may be relevant but please refer to the relevant official documentation or available resources for the latest information.

Frequently when writing TypeScript, you may need to create a new type from an existing type. For example, you may have a large type that you need to use in multiple places, and you want to create a new type that is a subset of the original type. Or you may have a large object full of data that you want to use to create types to maintain type safety. In this post, we'll cover how to create new types from existing types and data in TypeScript.

Accessing parts of a type with indexed access types

In JavaScript, you can access an object property's value with the string key of that property using someObject['someProperty']. You can use the same sort of syntax with TypeScript's types to get specific pieces out of a type.

For example:

type Pizza = {
  diameter: number;
  crust: 'thin' | 'thick' | 'stuffed';
  sauce: 'red' | 'white';
  toppings: Array<'pineapple' | 'pepperoni' | 'anchovy' | 'peppers' | 'olives' | 'mushrooms'>;
  customer: string;
}

type Diameter = Pizza["diameter"];
// type Diameter = number

type Toppings = Pizza["toppings"];
// type Toppings = Array<'pineapple' | 'pepperoni' | 'anchovy' | 'peppers' | 'olives' | 'mushrooms'>

Using TypeName["someProperty"] allows you to extract that piece of the type. These are called indexed access types. If you needed to use a piece of a large, complex type, you could simply pull that piece out into its own type using indexed access types.

Why indexed access types?

But what good is this? Couldn't I just refactor? In the previous example, wouldn't it be better for the pizza's Toppings to be a type of its own before defining Pizza, and then passed in as toppings: Toppings? I'd say yes, it would be. (And we'll cover that later!) But what if you're working with a type that you don't have control over (e.g., from a third party library), but you need to use a piece of it in a different type? That's where indexed access types come in.

Why not just use Pick?

Wait, why not just use Pick<SomeBigType, 'property'> instead of indexed access types? You would want to use the indexed access type when you want specifically a piece of the type, and not a type with that single property. For example:

type Pizza = {
  diameter: number;
  crust: 'thin' | 'thick' | 'stuffed';
  sauce: 'red' | 'white';
  toppings: Array<'pineapple' | 'pepperoni' | 'anchovy' | 'peppers' | 'olives' | 'mushrooms'>;
  customer: string;
}

type Toppings1 = Pick<Pizza, "toppings">;
// type Toppings1 = {
//   toppings: Array<'pineapple' | 'pepperoni' | 'anchovy' | 'peppers' | 'olives' | 'mushrooms'>;
// }

type Toppings2 = Pizza["toppings"];
// type Toppings2 = Array<'pineapple' | 'pepperoni' | 'anchovy' | 'peppers' | 'olives' | 'mushrooms'>;

The index is a type!

It isn't obvious from looking at the examples, but when you index a type, you're doing so with another type! So if I wanted to access a piece of a type with a defined string, it would fail. For example:

const key = "toppings"
type Toppings = Pizza[key]
// This fails with the following message:
// Type 'key' cannot be used as an index type.
// 'key' refers to a value, but is being used as a type here. Did you mean 'typeof key'?

In this case, I would instead have to use Pizza[typeof key] to get the same result as I would from just passing the value directly as Pizza["toppings"]. Alternatively, changing const key into type key would work.

Because the index is a type, you can pass a type in as the index. This lets me do things like tell TypeScript: "I want to create a type that could be any one of the items in this array". You would do this by using the type number as your index access type. For example, if I wanted to create a single Topping type from our Pizza example, I could do the following:

type Topping = Pizza['toppings'][number];
// type Topping = 'pineapple' | 'pepperoni' | 'anchovy' | 'peppers' | 'olives' | 'mushrooms';

Creating types with const assertions

Sometimes in TypeScript, you'll have some object full of data that you would like to use in a type-safe way. Let's return to our pizza example. Say we're building a web app to let people order our pizzas. Inside our order form, we have a list of toppings. This list of data could include a name, a description, and an extra price.

const TOPPINGS = [
  {
	name: 'pineapple',
	description: 'A delicious tropical fruit',
	price: 0.50,
  },
  {
	name: 'pepperoni',
	description: 'A spicy meat topping',
	price: 0.75,
  },
  {
	name: 'anchovy',
	description: 'A salty fish topping',
	price: 1.00,
  },
  {
	name: 'peppers',
	description: 'A colorful vegetable topping',
	price: 0.50,
  },
  {
	name: 'olives',
	description: 'A salty vegetable topping',
	price: 0.75,
  },
  {
	name: 'mushrooms',
	description: 'A savory vegetable topping',
	price: 0.50,
  },
];

Since we've gone through the trouble of writing all of this out, we should use this data to inform the Pizza type about our toppings. If we don't, it's both a duplication of code (a time-waster) and an opportunity for this data to get out of sync with our Pizza type.

For a first attempt, you might use the indexed access types we learned about earlier to get each of the topping names:

type Topping = typeof TOPPINGS[number]['name'];
// type Topping = string;

But that won't work! TypeScript has widened the type from those literal values to the broader string type. It doesn't assume that these values can't be changed later on. But it did notice that every name in TOPPINGS was a string, so it decided that the string type was the safest bet. Here, you can see how it would widely interpret the type of any entry in TOPPINGS:

type Toppings = typeof TOPPINGS[number];
// type Topping1 = {
//   name: string;
//   description: string;
//   price: number;
// }[]

This is a good default, but it's not what we want here.

The fix to this problem is easy: const assertions. We can simply append as const at the end of our TOPPINGS declaration. This tells TypeScript that we want to treat everything about this object as literal values that should not be widened. For example:

const TOPPINGS = [
  {
	name: 'pineapple',
	description: 'A delicious tropical fruit',
	price: 0.50,
  },
  {
	name: 'pepperoni',
	description: 'A spicy meat topping',
	price: 0.75,
  },
  // ...
] as const;
// ^ THIS is the important part

type Toppings = typeof TOPPINGS[number];
// type Toppings = [
//   {
// 	readonly name: "pineapple";
// 	readonly description: "A delicious tropical fruit";
// 	readonly price: 0.5;
// } | {
// 	readonly name: "pepperoni";
// 	readonly description: "A spicy meat topping";
// 	readonly price: 0.75;
// } |
//   And so on...
// ];

Now we've got a type with all of the literal values from TOPPINGS as readonly properties in our type! From here, we can use indexed access types to create our Topping type from the name property:

const TOPPINGS = [
  // All of the toppings...
] as const;
type Topping = typeof TOPPINGS[number]['name'];
// type Topping = "pineapple" | "pepperoni" | "anchovy" | "peppers" | "olives" | "mushrooms";

And we can use this type to inform our Pizza type:

type Pizza = {
  diameter: number;
  crust: 'thin' | 'thick' | 'stuffed';
  sauce: 'red' | 'white';
  toppings: Array<Topping>;
  customer: string;
}

Extra type safety with satisfies

Let's say we're factoring out the available crusts for making our Pizza. We could start with an array of strings, use a const assertion to use the literal values and avoid widening, and then again use our indexed access types to create a type from that array:

const CRUSTS = ['thin', 'thick',, 'stuffed'] as const;

type Crust = typeof CRUSTS[number];
// type Crust = 'thin' | 'thick' | 'stuffed' | undefined;

Well, almost there. Notice that we have an undefined type in there. That's because we have an extra comma in our array. This is effectively the same as saying ['thin', 'thick', undefined, 'stuffed'].

You could detect the undefined with type annotations, but that can't be mixed with const assertions. The type cannot both be string[] and readonly ['thin', 'thick', 'stuffed'].

// We can detect the undefined type, but we can't use const assertions
const CRUSTS: string[] = ['thin', 'thick',, 'stuffed'];
// Type '(string | undefined)[]' is not assignable to type 'string[]'

// Or we can use const assertions, but we can't detect the undefined type
const CRUSTS = ['thin', 'thick',, 'stuffed'] as const;
// const CRUSTS: readonly ["thin", "thick", undefined, "stuffed"]

// But we can't do both!
const CRUSTS: string[] = ['thin', 'thick', 'stuffed'] as const;
// The type 'readonly ["thin", "thick", "stuffed"]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.

To avoid this issue, we can use satisfies to confirm that the value conforms to a certain intended shape. In our case, we want to confirm that the array is a tuple of strings. We don't need TypeScript to confirm which strings exactly—only that it matches the intended shape.

// We detect the error, but without giving CRUSTS a specific type!
const CRUSTS = ['thin', 'thick',, 'stuffed'] satisfies string[];
// Type '(string | undefined)[]' does not satisfy the expected type 'string[]'.

We can further combine satifies with as const to get the literal values we want while verifying that the array is a tuple of strings:

// Assert that the values of the array are literal values that satisfy the type readonly string[]
const CRUSTS = ['thin', 'thick', 'stuffed'] as const satisfies readonly string[];

type Crust = typeof CRUSTS[number];
// type Crust = "thin" | "thick" | "stuffed"

With as const, we tell TypeScript that it should not widen the inferred type of CRUSTS and that we expect it to be the literal values given. And with satisfies readonly string[], we tell TypeScript that CRUSTS should satisfy the shape of an array of readonly strings. Now we can't accidentally add an extra comma or other value to the array, and we can still use the literal values from CRUST to create new types.

Conclusion

The combination of indexed access types, const assertions, and the satisfies operator, give us a lot of power to create types that are more specific, and more accurate. You can use them to transform your data into useful types, rather than attempting to duplicate that information manually, and inevitably having the data and types fall out of sync. This can ultimately save you and your team a lot of time, effort, and headache.

If you want to view the examples in this article in a runnable playground, you can find them at the TypeScript playground.

This Dot is a consultancy dedicated to guiding companies through their modernization and digital transformation journeys. Specializing in replatforming, modernizing, and launching new initiatives, we stand out by taking true ownership of your engineering projects.

We love helping teams with projects that have missed their deadlines or helping keep your strategic digital initiatives on course. Check out our case studies and our clients that trust us with their engineering.

You might also like

Understanding Vue's Reactive Data cover image

Understanding Vue's Reactive Data

Introduction Web development has always been about creating dynamic experiences. One of the biggest challenges developers face is managing how data changes over time and reflecting these changes in the UI promptly and accurately. This is where Vue.js, one of the most popular JavaScript frameworks, excels with its powerful reactive data system. In this article, we dig into the heart of Vue's reactivity system. We unravel how it perfectly syncs your application UI with the underlying data state, allowing for a seamless user experience. Whether new to Vue or looking to deepen your understanding, this guide will provide a clear and concise overview of Vue's reactivity, empowering you to build more efficient and responsive Vue 3 applications. So, let’s kick off and embark on this journey to decode Vue's reactive data system. What is Vue's Reactive Data? What does it mean for data to be ”'reactive”? In essence, when data is reactive, it means that every time the data changes, all parts of the UI that rely on this data automatically update to reflect these changes. This ensures that the user is always looking at the most current state of the application. At its core, Vue's Reactive Data is like a superpower for your application data. Think of it like a mirror - whatever changes you make in your data, the user interface (UI) reflects these changes instantly, like a mirror reflecting your image. This automatic update feature is what we refer to as “reactivity”. To visualize this concept, let's use an example of a simple Vue application displaying a message on the screen: ` In this application, 'message' is a piece of data that says 'Hello Vue!'. Let's say you change this message to 'Goodbye Vue!' later in your code, like when a button is clicked. ` With Vue's reactivity, when you change your data, the UI automatically updates to 'Goodbye Vue!' instead of 'Hello Vue!'. You don't have to write extra code to make this update happen - Vue's Reactive Data system takes care of it. How does it work? Let's keep the mirror example going. Vue's Reactive Data is the mirror that reflects your data changes in the UI. But how does this mirror know when and what to reflect? That's where Vue's underlying mechanism comes into play. Vue has a behind-the-scenes mechanism that helps it stay alerted to any changes in your data. When you create a reactive data object, Vue doesn't just leave it as it is. Instead, it sends this data object through a transformation process and wraps it up in a Proxy. Proxy objects are powerful and can detect when a property is changed, updated, or deleted. Let's use our previous example: ` Consider our “message” data as a book in a library. Vue places this book (our data) within a special book cover (the Proxy). This book cover is unique - it's embedded with a tracking device that notifies Vue every time someone reads the book (accesses the data) or annotates a page (changes the data). In our example, the reactive function creates a Proxy object that wraps around our state object. When you change the 'message': ` The Proxy notices this (like a built-in alarm going off) and alerts Vue that something has changed. Vue then updates the UI to reflect this change. Let’s look deeper into what Vue is doing for us and how it transforms our object into a Proxy object. You don't have to worry about creating or managing the Proxy; Vue handles everything. ` In the example above, we encapsulate our object, in this case, “state”, converting it into a Proxy object. Note that within the second argument of the Proxy, we have two methods: a getter and a setter. The getter method is straightforward: it merely returns the value, which in this instance is “state.message” equating to 'Hello Vue!' Meanwhile, the setter method comes into play when a new value is assigned, as in the case of “state.message = ‘Hey young padawan!’”. Here, “value” becomes our new 'Hey young padawan!', prompting the property to update. This action, in turn, triggers the reactivity system, which subsequently updates the DOM. Venturing Further into the Depths If you have been paying attention to our examples above, you might have noticed that inside the Proxy method, we call the functions track and trigger to run our reactivity. Let’s try to understand a bit more about them. You see, Vue 3 reactivity data is more about Proxy objects. Let’s create a new example: ` In this example, when you click on the button, the message's value changes. This change triggers the effect function to run, as it's actively listening for any changes in its dependencies. How does the effect property know when to be called? Vue 3 has three main functions to run our reactivity: effect, track, and trigger. The effect function is like our supervisor. It steps in and takes action when our data changes – similar to our effect method, we will dive in more later. Next, we have the track function. It notes down all the important data we need to keep an eye on. In our case, this data would be state.message. Lastly, we've got the trigger function. This one is like our alarm bell. It alerts the effect function whenever our important data (the stuff track is keeping an eye on) changes. In this way, trigger, track, and effect work together to keep our Vue application reacting smoothly to changes in data. Let’s go back to them: ` Tracking (Dependency Collection) Tracking is the process of registering dependencies between reactive objects and the effects that depend on them. When a reactive property is read, it's "tracked" as a dependency of the current running effect. When we execute track(), we essentially store our effects in a Set object. But what exactly is an "effect"? If we revisit our previous example, we see that the effect method must be run whenever any property changes. This action — running the effect method in response to property changes — is what we refer to as an "Effect"! (computed property, watcher, etc.) > Note: We'll outline a basic, high-level overview of what might happen under the hood. Please note that the actual implementation is more complex and optimized, but this should give you an idea of how it works. Let’s see how it works! In our example, we have the following reactive object: ` We need a way to reference the reactive object with its effects. For that, we use a WeakMap. Which type is going to look something like this: ` We are using a WeakMap to set our object state as the target (or key). In the Vue code, they call this object targetMap. Within this targetMap object, our value is an object named depMap of Map type. Here, the keys represent our properties (in our case, that would be message and showSword), and the values correspond to their effects – remember, they are stored in a Set that in Vue 3 we refer to as dep. Huh… It might seem a bit complex, right? Let's make it more straightforward with a visual example: With the above explained, let’s see what this Track method kind of looks like and how it uses this targetMap. This method essentially is doing something like this: ` At this point, you have to be wondering, how does Vue 3 know what activeEffect should run? Vue 3 keeps track of the currently running effect by using a global variable. When an effect is executed, Vue temporarily stores a reference to it in this global variable, allowing the track function to access the currently running effect and associate it with the accessed reactive property. This global variable is called inside Vue as activeEffect. Vue 3 knows which effect is assigned to this global variable by wrapping the effects functions in a method that invokes the effect whenever a dependency changes. And yes, you guessed, that method is our effect method. ` This method behind the scenes is doing something similar to this: ` The handling of activeEffect within Vue's reactivity system is a dance of careful timing, scoping, and context preservation. Let’s go step by step on how this is working all together. When we run our Effect method for the first time, we call the get trap of the Proxy. ` When running the get trap, we have our activeEffect so we can store it as a dependency. ` This coordination ensures that when a reactive property is accessed within an effect, the track function knows which effect is responsible for that access. Trigger Method Our last method makes this Reactive system to be complete. The trigger method looks up the dependencies for the given target and key and re-runs all dependent effects. ` Conclusion Diving into Vue 3's reactivity system has been like unlocking a hidden superpower in my web development toolkit, and honestly, I've had a blast learning about it. From the rudimentary elements of reactive data and instantaneous UI updates to the intricate details involving Proxies, track and trigger functions, and effects, Vue 3's reactivity is an impressively robust framework for building dynamic and responsive applications. In our journey through Vue 3's reactivity, we've uncovered how this framework ensures real-time and precise updates to the UI. We've delved into the use of Proxies to intercept and monitor variable changes and dissected the roles of track and trigger functions, along with the 'effect' method, in facilitating seamless UI updates. Along the way, we've also discovered how Vue ingeniously manages data dependencies through sophisticated data structures like WeakMaps and Sets, offering us a glimpse into its efficient approach to change detection and UI rendering. Whether you're just starting with Vue 3 or an experienced developer looking to level up, understanding this reactivity system is a game-changer. It doesn't just streamline the development process; it enables you to create more interactive, scalable, and maintainable applications. I love Vue 3, and mastering its reactivity system has been enlightening and fun. Thanks for reading, and as always, happy coding!...

Implementing Dynamic Types in Docusign Extension Apps cover image

Implementing Dynamic Types in Docusign Extension Apps

Implementing Dynamic Types in Docusign Extension Apps In our previous blog post about Docusign Extension Apps, Advanced Authentication and Onboarding Workflows with Docusign Extension Apps, we touched on how you can extend the OAuth 2 flow to build a more powerful onboarding flow for your Extension Apps. In this blog post, we will continue explaining more advanced patterns in developing Extension Apps. For that reason, we assume at least basic familiarity with how Extension Apps work and ideally some experience developing them. To give a brief recap, Docusign Extension Apps are a powerful way to embed custom logic into Docusign agreement workflows. These apps are lightweight services, typically cloud-hosted, that integrate at specific workflow extension points to perform custom actions, such as data validation, participant input collection, or interaction with third-party services. Each Extension App is configured using a manifest file. This manifest defines metadata such as the app's author, support links, and the list of extension points it uses (these are the locations in the workflow where your app's logic will be executed). The extension points that are relevant for us in the context of this blog post are GetTypeNames and GetTypeDefinitions. These are used by Docusign to retrieve the types supported by the Extension App and their definitions, and to show them in the Maestro UI. In most apps, these types are static and rarely change. However, they don't have to be. They can also be dynamic and change based on certain configurations in the target system that the Extension App is integrating with, or based on the user role assigned to the Maestro administrator on the target system. Static vs. Dynamic Types To explain the difference between static and dynamic types, we'll use the example from our previous blog post, where we integrated with an imaginary task management system called TaskVibe. In the example, our Extension App enabled agreement workflows to communicate with TaskVibe, allowing tasks to be read, created, and updated. Our first approach to implementing the GetTypeNames and GetTypeDefinitions endpoints for the TaskVibe Extension App might look like the following. The GetTypeNames endpoint returns a single record named task: ` Given the type name task, the GetTypeDefinitions endpoint would return the following definition for that type: ` As noted in the Docusign documentation, this endpoint must return a Concerto schema representing the type. For clarity, we've omitted most of the Concerto-specific properties. The above declaration states that we have a task type, and this type has properties that correspond to task fields in TaskVibe, such as record ID, title, description, assignee, and so on. The type definition and its properties, as described above, are static and they never change. A TaskVibe task will always have the same properties, and these are essentially set in stone. Now, imagine a scenario where TaskVibe supports custom properties that are also project-dependent. One project in TaskVibe might follow a typical agile workflow with sprints, and the project manager might want a "Sprint" field in every task within that project. Another project might use a Kanban workflow, where the project manager wants a status field with values like "Backlog," "ToDo," and so on. With static types, we would need to return every possible field from any project as part of the GetTypeDefinitions response, and this introduces new challenges. For example, we might be dealing with hundreds of custom field types, and showing them in the Maestro UI might be too overwhelming for the Maestro administrator. Or we might be returning fields that are simply not usable by the Maestro administrator because they relate to projects the administrator doesn't have access to in TaskVibe. With dynamic types, however, we can support this level of customization. Implementing Dynamic Types When Docusign sends a request to the GetTypeNames endpoint and the types are dynamic, the Extension App has a bit more work than before. As we've mentioned earlier, we can no longer return a generic task type. Instead, we need to look into each of the TaskVibe projects the user has access to, and return the tasks as they are represented under each project, with all the custom fields. (Determining access can usually be done by making a query to a user information endpoint on the target system using the same OAuth 2 token used for other calls.) Once we find the task definitions on TaskVibe, we then need to return them in the response of GetTypeNames, where each type corresponds to a task for the given project. This is a big difference from static types, where we would only return a single, generic task. For example: ` The key point here is that we are now returning one type per task in a TaskVibe project. You can think of this as having a separate class for each type of task, in object-oriented lingo. The type name can be any string you choose, but it needs to be unique in the list, and it needs to contain the minimum information necessary to be able to distinguish it from other task definitions in the list. In our case, we've decided to form the ID by concatenating the string "task_" with the ID of the project on TaskVibe. The implementation of the GetTypeDefinitions endpoint needs to: 1. Extract the project ID from the requested type name. 1. Using the project ID, retrieve the task definition from TaskVibe for that project. This definition specifies which fields are present on the project's tasks, including all custom fields. 1. Once the fields are retrieved, map them to the properties of the Concerto schema. The resulting JSON could look like this (again, many of the Concerto properties have been omitted for clarity): ` Now, type definitions are fully dynamic and project-dependent. Caching of Type Definitions on Docusign Docusign maintains a cache of type definitions after an initial connection. This means that changes made to your integration (particularly when using dynamic types) might not be immediately visible in the Maestro UI. To ensure users see the latest data, it's useful to inform them that they may need to refresh their Docusign connection in the App Center UI if new fields are added to their integrated system (like TaskVibe). As an example, a newly added custom field on a TaskVibe project wouldn't be reflected until this refresh occurs. Conclusion In this blog post, we've explored how to leverage dynamic types within Docusign Extension Apps to create more flexible integrations with external systems. While static types offer simplicity, they can be constraining when working with external systems that offer a high level of customization. We hope that this blog post provides you with some ideas on how you can tackle similar problems in your Extension Apps....

Introduction to Zod for Data Validation cover image

Introduction to Zod for Data Validation

As web developers, we're often working with data from external sources like APIs we don't control or user inputs submitted to our backends. We can't always rely on this data to take the form we expect, and we can encounter unexpected errors when it deviates from expectations. But with the Zod library, we can define what our data ought to look like and parse the incoming data against those defined schemas. This lets us work with that data confidently, or to quickly throw an error when it isn't correct. Why use Zod? TypeScript is great for letting us define the shape of our data in our code. It helps us write more correct code the first time around by warning us if we are doing something we shouldn't. But TypeScript can't do everything for us. For example, we can define a variable as a string or a number, but we can't say "a string that starts with user_id_ and is 20 characters long" or "an integer between 1 and 5". There are limits to how much TypeScript can narrow down our data for us. Also, TypeScript is a tool for us developers. When we compile our code, our types are not available to the vanilla JavaScript. JavaScript can't validate that the data we actually use in our code matches what we thought we'd get when we wrote our TypeScript types unless you're willing to manually write code to perform those checks. This is where we can reach for a tool like Zod. With Zod, we can write data schemas. These schemas, in the simplest scenarios, look very much like TypeScript types. But we can do more with Zod than we can with TypeScript alone. Zod schemas let us create additional rules for data parsing and validation. A 20-character string that starts with user_id_? It's z.string().startsWith('user_id_').length(20). An integer between 1 and 5 inclusive? It's z.number().int().gte(1).lte(5). Zod's primitives give us many extra functions to be more specific about *exactly* what data we expect. Unlike TypeScript, Zod schemas aren't removed on compilation to JavaScript—we still have access to them! If our app receives some data, we can verify that it matches the expected shape by passing it to your Zod schema's parse function. You'll either get back your data in exactly the shape you defined in your schema, or Zod will give you an error to tell you what was wrong. Zod schemas aren't a replacement for TypeScript; rather, they are an excellent complement. Once we've defined our Zod schema, it's simple to derive a TypeScript type from it and to use that type as we normally would. But when we really need to be sure our data conforms to the schema, we can always parse the data with our schema for that extra confidence. Defining Data Schemas Zod schemas are the variables that define our expectations for the shape of our data, validate those expectations, and transform the data if necessary to match our desired shape. It's easy to start with simple schemas, and to add complexity as required. Zod provides different functions that represent data structures and related validation options, which can be combined to create larger schemas. In many cases, you'll probably be building a schema for a data object with properties of some primitive type. For example, here's a schema that would validate a JavaScript object representing an order for a pizza: ` Zod provides a number of primitives for defining schemas that line up with JavaScript primitives: string, number, bigint, boolean, date, symbol, undefined, and null. It also includes primitives void, any, unknown, and never for additional typing information. In addition to basic primitives, Zod can define object, array, and other native data structure schemas, as well as schemas for data structures not natively part of JavaScript like tuple and enum. The documentation contains considerable detail on the available data structures and how to use them. Parsing and Validating Data with Schemas With Zod schemas, you're not only telling your program what data should look like; you're also creating the tools to easily verify that the incoming data matches the schema definitions. This is where Zod really shines, as it greatly simplifies the process of validating data like user inputs or third party API responses. Let's say you're writing a website form to register new users. At a minimum, you'll need to make sure the new user's email address is a valid email address. For a password, we'll ask for something at least 8 characters long and including one letter, one number, and one special character. (Yes, this is not really the best way to write strong passwords; but for the sake of showing off how Zod works, we're going with it.) We'll also ask the user to confirm their password by typing it twice. First, let's create a Zod schema to model these inputs: ` So far, this schema is pretty basic. It's only making sure that whatever the user types as an email is an email, and it's checking that the password is at least 8 characters long. But it is *not* checking if password and confirmPassword match, nor checking for the complexity requirements. Let's enhance our schema to fix that! ` By adding refine with a custom validation function, we have been able to verify that the passwords match. If they don't, parsing will give us an error to let us know that the data was invalid. We can also chain refine functions to add checks for our password complexity rules: ` Here we've chained multiple refine functions. You could alternatively use superRefine, which gives you even more fine grained control. Now that we've built out our schema and added refinements for extra validation, we can parse some user inputs. Let's see two test cases: one that's bound to fail, and one that will succeed. ` There are two main ways we can use our schema to validate our data: parse and safeParse. The main difference is that parse will throw an error if validation fails, while safeParse will return an object with a success property of either true or false, and either a data property with your parsed data or an error property with the details of a ZodError explaining why the parsing failed. In the case of our example data, userInput2 will parse just fine and return the data for you to use. But userInput1 will create a ZodError listing all of the ways it has failed validation. ` ` We can use these error messages to communicate to the user how they need to fix their form inputs if validation fails. Each error in the list describes the validation failure and gives us a human readable message to go with it. You'll notice that the validation errors for checking for a valid email and for checking password length have a lot of details, but we've got three items at the end of the error list that don't really tell us anything useful: just a custom error of Invalid input. The first is from our refine checking if the passwords match, and the second two are from our refine functions checking for password complexity (numbers and special characters). Let's modify our refine functions so that these errors are useful! We'll add our own error parameters to customize the message we get back and the path to the data that failed validation. ` Now, our error messages from failures in refine are informative! You can figure out which form fields aren't validating from the path, and then display the messages next to form fields to let the user know how to remedy the error. ` By giving our refine checks a custom path and message, we can make better use of the returned errors. In this case, we can highlight specific problem form fields for the user and give them the message about what is wrong. Integrating with TypeScript Integrating Zod with TypeScript is very easy. Using z.infer&lt;typeof YourSchema> will allow you to avoid writing extra TypeScript types that merely reflect the intent of your Zod schemas. You can create a type from any Zod schema like so: ` Using a TypeScript type derived from a Zod schema does *not* give you any extra level of data validation at the type level beyond what TypeScript is capable of. If you create a type from z.string.min(3).max(20), the TypeScript type will still just be string. And when compiled to JavaScript, even that will be gone! That's why you still need to use parse/safeParse on incoming data to validate it before proceeding as if it really does match your requirements. A common pattern with inferring types from Zod schemas is to use the same name for both. Because the schema is a variable, there's no name conflict if the type uses the same name. However, I find that this can lead to confusing situations when trying to import one or the other—my personal preference is to name the Zod schema with Schema at the end to make it clear which is which. Conclusion Zod is an excellent tool for easily and confidently asserting that the data you're working with is exactly the sort of data you were expecting. It gives us the ability to assert at runtime that we've got what we wanted, and allows us to then craft strategies to handle what happens if that data is wrong. Combined with the ability to infer TypeScript types from Zod schemas, it lets us write and run more reliable code with greater confidence....

This Dot AI Field Notes - Anatomy of a Coding Harness cover image

This Dot AI Field Notes - Anatomy of a Coding Harness

A coding agent is not magic, it’s a loop. We call this a harness. The harness is a deterministic layer of code that wraps an LLM. Claude Code is a harness. Codex is a harness. Pi is a harness. The harness, on initialization, provides to the LLM a system prompt defining all tools the harness implements for the LLM. Without the harness, you cannot read or modify files on the user’s local filesystem without them having to copy-and-pasting by hand. The harness is the final place where engineers can customize how coding agents do work before the LLM takes over. Think of the LLM as a train and the harness as the rails the train rides on. Below… one full task executed by a harness, traced step by step....

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co