Typescript and JSDoc, shared names for Types + Values

(written by a human, no AI content)

I saw this pattern recently, taking advantage of the fact that Typescript doesn't mind if you re-use the same name for a value and a type.

import * as z from "zod"

// Define a schema
export const User = z.object({
    name: z.string()
})

// export an inferred type for use elsewhere
// but, using the same name, `User` 🤯
export type User = z.infer<typeof User>

So we have...

  • export const User ...
  • export type User...

... it might seem odd, but Typescript is more than happy with this - after all, types and values are distinct! So, providing that you're happy with any potential confusion in the future , it's a pattern you can use right away!

Why would you though? Well, it might make more sense in the context of a consumer - consider the following in a separate module:

import { User } from "./zod"

function signIn(user: User) {
    console.log(user);
}

Here we have a single import: User. We know the module exports both a type and a value with that name - but when seen in type parameter position like this, Typescript will just use the type User without issue. A naming 'collision' just doesn't occur.

In that very same file, we could also do:

// same file, different context
const parsed = User.safeParse({ name: "Shane" })

which is nice - because now we're just in regular JavaScript territory here, using the value called User. Typescript understands that we're not in a type position, so everything 'just works' and we didn't need to think of a separate name for it, like UserSchema or similar. 🥰

JSDoc

All of the examples above were in Typescript - but if you're using JSDoc this works almost identically.

First, instead of exporting both of these...

  • export const User ...
  • export type User ...

we'll need to replace the second one - since export type ... is only supported in Typescript.

To do this, albeit in a less-than-ideal way, we can use a @typedef

import * as z from "zod"

export const User = z.object({
    name: z.string()
})

/**
 * @typedef {import("zod").infer<typeof User>} User
 */

Using infer from Zod, we're achieving the exact same inference as we did in Typescript - it's just that it's inside a comment this time instead!🥹.

@typedef isn't ideal for all situations (for another time...), but in this case it has exactly the effect we're looking for.

Considering a consumer in JS, as we did with the Typescript example, it would look like this:

import { User } from "./jsdoc-zod";

/**
 * @param {User} user
 */
function signIn(user) {
    console.log(user);
}

Again, note we're only importing User - which is both a type and a value in the other module. Typescript still has no issue understanding that within a @param block, we are referring to the type and not the value.

That means the previous "in the same file" example works just as it did before, presented here as a single snippet:

import { User } from "./jsdoc-zod";

/**
 * @param {User} user - works as expected in type position 🥰
 */
function signIn(user) {
    console.log(user);
}

// works as expected as a value too 🥰
const parsed = User.safeParse({ name: "shane" })

Conclusion

It's becoming table-stakes for any schema parsing library like Zod to support rich type inference. Having types derived from runtime validation code is a powerful pattern, one that I can see growing in popularity.

This post highlights how you can use a single name for a value and a type - and how it can be done in JSDoc along with Typescript.