zod icon indicating copy to clipboard operation
zod copied to clipboard

Add `ZodTemplateLiteral`

Open igalklebanov opened this issue 2 years ago • 15 comments

Hey 👋

Closes #419.

  • [x] implement.
  • [x] unit test.
  • [x] document.

This PR adds ZodTemplateLiteral for building template literals. Should be used when a consumer wants to explictly get a template literal typescript type / union.

A typescript template literal consists of string literal types and types in interpolated positions (the ${} slots).

Thus this new class has the following methods:

  • .interpolated(type) accepts zod types with a string literal fitting output type (basically primitives).
  • .literal(value) accepts primitives. A shorthand for .interpolated(z.literal(value)).

These methods append the argument to the accumulated template literal. Validation is regular expression based.

API:

import { z } from 'zod'

const url = z.templateLiteral()
             .literal('https://')
             .interpolated(z.string().min(1))
             .literal('.')
             .interpolated(z.enum(['com', 'net']))
type URL = z.infer<typeof url> // `https://${string}.com` | `https://${string}.net`

(see unit tests for more examples).

igalklebanov avatar Dec 31 '22 00:12 igalklebanov

Deploy Preview for guileless-rolypoly-866f8a failed.

Name Link
Latest commit 01065e8be44c93b18b3a486064a53d4efeb55169
Latest deploy log https://app.netlify.com/sites/guileless-rolypoly-866f8a/deploys/663d4d0c815550000823be3f

netlify[bot] avatar Dec 31 '22 00:12 netlify[bot]

Infer works thus far. Going to bed, will continue tomorrow. 💤

igalklebanov avatar Dec 31 '22 01:12 igalklebanov

@maxArturo Thank you for the thorough review and great feedback! 💪

igalklebanov avatar Jan 20 '23 12:01 igalklebanov

Any plans to add this?

spiftire avatar Feb 16 '23 06:02 spiftire

Did you consider this API? image

A quick dirty implementation:

import { z } from "zod";

// this is already present in zod codebase
type Writeable<T> = { -readonly [P in keyof T]: T[P] };

type _TemplateLiteralPartOutput =
  | string
  | number
  | boolean
  | bigint
  | null
  | undefined;
type _TemplateLiteralPartInput =
  | _TemplateLiteralPartOutput
  | z.ZodType<_TemplateLiteralPartOutput>;
type _MapPart<T extends _TemplateLiteralPartInput> = T extends z.ZodTypeAny
  ? T["_output"]
  : T;
type _ConcatParts<T extends _TemplateLiteralPartInput[]> = T extends [
  infer THead extends _TemplateLiteralPartInput,
  ...infer TTail extends _TemplateLiteralPartInput[]
]
  ? `${_MapPart<THead>}${_ConcatParts<TTail>}`
  : "";

function templateLiteral<T extends readonly _TemplateLiteralPartInput[]>(
  parts: T
): _ConcatParts<Writeable<T>> {
  throw "implementation here";
}

const a = templateLiteral([
  `hello `,
  z.string(),
  "! I am ",
  z.number(),
  " years old",
] as const);

olehmisar avatar Mar 29 '23 20:03 olehmisar

@olehmisar Yeah. Read @colinhacks's comments here (which I agree with).

igalklebanov avatar Mar 29 '23 20:03 igalklebanov

Damn, looks like its failing some tests now. Will fix that later today.

EDIT: didn't handle case insensitive regular expressions (/.../i).

igalklebanov avatar May 22 '23 09:05 igalklebanov

Hey @igalklebanov, any updates on this? Great work btw. 😄

Are you still considering @olehmisar 's API?

KholdStare avatar Sep 14 '23 15:09 KholdStare

Hey @igalklebanov, any updates on this? Great work btw.

None.

Are you still considering @olehmisar 's API?

Never considered. It goes against the author's current design principles.

igalklebanov avatar Sep 14 '23 16:09 igalklebanov

Thanks! I think I misread the comment regarding the other API. Anything stopping this from being merged? Looks like everything is passing (except prettier check on README)

KholdStare avatar Sep 14 '23 16:09 KholdStare

I was looking for exactly a feature like this. Is there something blocking this PR, or just lack of bandwidth to review it?

Note: in the meantime I was able to use z.custom() to achieve what I wanted, but I can see where this method would also come in handy.

Edit: I might be holding it wrong, but I'm having some trouble with z.custom() because the generic type doesn't allow different Input and Output, which I need. For example, I have a form that starts out empty, but when it submits, I want to validate that the shape matches a particular pattern. Would this PR allow that kind of situation a bit better than z.custom() does?

IanVS avatar Feb 05 '24 14:02 IanVS

@colinhacks Can this be merged?

KholdStare avatar Feb 05 '24 14:02 KholdStare

As time goes on I feel like this would be more and more useful for us every day. I've now got tons of cases where we use templated strings to differentiate different types of ids such as built-in vs custom entites, or for type-safe hierarchies (or both):

type ContentTypeId = "Link" | "Video" | "Custom:${string}"
type ContentId = "${ContentTypeId}:${string}"

type CategoryId = "Sponsor" | "Resource" | "Custom:${string}"
type SubCategoryId = "${CategoryId}:${string}"

Manually managing regex().transform() is just becoming increasingly painful and error prone, especially with the nested/hierarchical types. I think this is literally the only thing that we're really missing from Zod.

MikeRippon avatar Mar 05 '24 19:03 MikeRippon

This is a tough one. For starters, this isn't the API I would advocate for - we can use tagged template literals here to make the API more isomorphic with the represented type. I've done some experiments and I believe this is workable.

z.template`asdf${z.string()}${z.number()}`;

That said, this is a lot of code and complexity (2k LOC) for a pretty niche feature. I see all the upvotes/reactions, but the total numbers are still fairly small. This also falls into the set of features (like z.discriminatedUnion) that are inelegant to implement, because they require a bunch of switch-like logic over multiple Zod subclasses to implement correctly.

All told I'm not super enthusiastic about this in the short term. Zod 4 will have better treeshakability characteristics, which changes the calculus on whether to merge obscure-ish features like this. I'll leave this open and try to keep people posted as Zod 4 comes along.

colinhacks avatar Apr 07 '24 01:04 colinhacks

@colinhacks Your suggested API is cleaner for sure. Agree with all points. Excited about v4!

How do you infer the literal text outside the interpolated positions?

image https://tsplay.dev/N5agMN

igalklebanov avatar Apr 07 '24 16:04 igalklebanov

Oops, yeah the tagged template literal API won't work. I got excited that the interpolations could be inferred but apparently the literal parts can't (as Igal said). Bummer.


I just rebased this onto v4 and made some changes. cc @igalklebanov

z.literal.template([ "asdf", z.number() ]);

z.literal.template

I like the dot-chaining thing, similar to z.coerce. And since this is ultimately a special kind of literal type, I think it makes sense to use z.literal.template. A few other APIs in Zod 4 will follow this pattern as well.

Array API

I see this was discussed above. In this case, I think an array-based API is fine here. My concerns about using arrays in .pick/.omit do not apply to the same extent. Users often have arrays of keys laying about from external tools or Object.keys(). Trying to pass these into .pick() would be problematic. I also like the isomorphism of using object masks to pick/omit an object schema.

In this case though, the Parts argument is a specific mix of literals and schemas that's less likely to be declared externally. And an array is more isomorphic to the syntax of a template literal.

Looser typings

The z.literal.template API no longer tries to statically prevent ZodNan, ZodPipeline, etc. I'm increasingly of the opinion it's better to provide an informative runtime error here, instead of an obscure assignment error.

Dropped coercion for the moment.

.z.coerce.literal.template was a bit much. I have yet to get requests for z.coerce.literal so I don't think there's much demand here. Let's wait and see for now.


I tried using const parameters for the first time, since they were introduced in TS 5.0, and Zod 4 will require TS 5.0+. But for some reason the const inference didn't seem to work properly until TS 5.3 🤷‍♂️ It's a bit disappointing, and I couldn't see anything in the release notes for 5.3 to explain this. Strange.

colinhacks avatar May 09 '24 22:05 colinhacks

Gonna merge this into v4 now. If there are any serious concerns with the new API, etc, there's plenty of time to address that before the v4 release in followup PRs.

Igal, amazing work on this! 🙌

colinhacks avatar May 09 '24 23:05 colinhacks