How to Keep JSDoc with Zod Types

This article was published on Jul 25, 2025, and takes approximately 2 minutes to read.

As someone who writes a lot of libraries and shared code and also loves modern type-safe schema libraries such as Zod, one common problem I always try to address is providing useful information via JSDoc for options, functions, and methods so my users donโ€™t have to leave their IDE to check the docs.

JSDocs in action
JSDocs in action

Most of the time I use Zod to create compile-time and runtime validations, and the experience has been truly amazing. I can define default values, perform value coercion, and provide friendly error messages without having to write imperative checks.

My strategy has always been to create a schema and then create a type that derives from it:

import { z } from "zod";

const Options = z.object({
	/** The port for your application
	 *
	 * @default 3000
	 */
	port?: z.coerce.number().default(3000),
});

export type Options = z.output<typeof Options>;

When I hover Options, I expect to see the documentation:

Hovering options type to see the interface
Options hover on VSCode

โ€ฆand this is where the problem starts.

As you can see, when I hover it, the JSDoc containing the explanation and the default value is gone.

Iโ€™ve seen some issues on GitHub where people say it works in their IDE, but I have never been able to make it work in either VS Code or Zed, so I wondered if there was a way to solve this that would work for everyone.

And yes, there is.

To address this problem, we can invert the logic: instead of deriving the interface from the schema definition, create the interface first and force the schema to implement it:

import { z } from "zod";

interface Options {
	/** The port for your application
	 *
	 * @default 3000
	 */
	port?: number;
}

const Options = z.object({
	port: z.coerce.number().default(3000),
}) satisfies z.ZodType<Options>;

By using the satisfies operator together with the z.ZodType utility type, we enforce the schema to match the interface we defined. It also means that if I add a new property to my Options interface, TypeScript validation will fail because the schema is missing this property.

import { z } from "zod";

interface Options {
	/** The port for your application
	 *
	 * @default 3000
	 */
	port?: number;
  /** The base URL for your application */
  baseUrl: string;
}

const Options = z.object({
	port: z.coerce.number().default(3000),
}) satisfies z.ZodType<Options>;
/*
    ๐Ÿ‘†๐Ÿฝ
Type 'ZodObject<{ port: ZodDefault<ZodCoercedNumber<unknown>>; }, $strip>' does not satisfy the expected type 'ZodType<Options, unknown, $ZodTypeInternals<Options, unknown>>'.
  Types of property '_output' are incompatible.
    Property 'baseUrl' is missing in type '{ port: number; }' but required in type 'Options'.
*/

Another case where this approach shines is with optionals.

As you can see, port is optional and falls back to 3000. However, when I derive the type from the schema, port becomes required:

import { z } from "zod";

const Options = z.object({
	port: z.coerce.number().default(3000),
});

type Options = z.output<typeof Options>;
/* 
       ๐Ÿ‘†๐Ÿฝ
type Options = {
    port: number;
}
*/

So, if I use Options as a function argument, my users will have to specify port, even though it defaults to 3000 โ€”and thatโ€™s not what I want.

If you think about how TypeScript inference works (and Zod builds on top of it), this makes sense. Even if we add .optional(), the .default(3000) guarantees the value is present, so the field is no longer considered optional.

By defining the interface first, I can rely on my JSDoc comments and be more precise about what the user needs to provide.

I havenโ€™t yet found a better or easier way to achieve this. To me, it seems more like a TypeScript limitation than a library issue. But honestly, the trade-off is minimal compared with the benefits.

References