Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add z.literal.template() #1786

Merged
merged 44 commits into from
May 9, 2024
Merged

Conversation

igalklebanov
Copy link
Contributor

@igalklebanov igalklebanov commented Dec 31, 2022

[EDIT by @colinhacks]

This PR has been updated to support an array-based API:

import * as z from "zod";

const urlSchema = z.literal.template([
  "http",
  z.literal("s").optional(),
  "://",
  z.string(),
  ".",
  z.enum(["com", "org"]),
]);

type urlSchema = z.infer<typeof urlSchema>;
// `http://${string}.com` | `https://${string}.com` | `http://${string}.org` | `https://${string}.org`

The parsing remains early identical. The OP's original comment is below. Thanks Igal!


Hey 👋

Closes #419.

  • implement.
  • unit test.
  • 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).

@netlify
Copy link

netlify bot commented Dec 31, 2022

Deploy Preview for guileless-rolypoly-866f8a failed.

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

@igalklebanov
Copy link
Contributor Author

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

Copy link

@naorpeled naorpeled left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! 😎

Copy link
Contributor

@maxArturo maxArturo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@igalklebanov thanks! This is some awesome functionality 🎉 . I added a couple of things I found but overall this is great!

src/__tests__/template-literal.test.ts Outdated Show resolved Hide resolved
src/types.ts Outdated Show resolved Hide resolved
src/types.ts Outdated Show resolved Hide resolved
src/types.ts Show resolved Hide resolved
src/types.ts Outdated Show resolved Hide resolved
src/__tests__/template-literal.test.ts Show resolved Hide resolved
Copy link
Contributor

@maxArturo maxArturo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 ! Looks good to me. Just one thought on the new error classes but its not a blocker for this to be merged IMO. Thank you again for your timely fixes 🙏

deno/lib/__tests__/template-literal.test.ts Show resolved Hide resolved
src/types.ts Show resolved Hide resolved
src/types.ts Outdated Show resolved Hide resolved
src/types.ts Show resolved Hide resolved
@igalklebanov
Copy link
Contributor Author

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

@spiftire
Copy link

Any plans to add this?

@olehmisar
Copy link

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

@igalklebanov
Copy link
Contributor Author

igalklebanov commented Mar 29, 2023

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

@igalklebanov
Copy link
Contributor Author

igalklebanov commented May 22, 2023

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

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

@KholdStare
Copy link

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

Are you still considering @olehmisar 's API?

@igalklebanov
Copy link
Contributor Author

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.

@KholdStare
Copy link

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)

@IanVS
Copy link

IanVS commented Feb 5, 2024

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?

@KholdStare
Copy link

@colinhacks Can this be merged?

@MikeRippon
Copy link

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.

@colinhacks
Copy link
Owner

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 colinhacks added the zod4 label Apr 7, 2024
@igalklebanov
Copy link
Contributor Author

igalklebanov commented Apr 7, 2024

@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

@colinhacks colinhacks changed the title add ZodTemplateLiteral. Add ZodTemplateLiteral May 3, 2024
igalklebanov and others added 18 commits May 9, 2024 13:54
Co-authored-by: Max Arturo <5713763+maxArturo@users.noreply.github.com>
Co-authored-by: Max Arturo <5713763+maxArturo@users.noreply.github.com>
Co-authored-by: Max Arturo <5713763+maxArturo@users.noreply.github.com>
Co-authored-by: Max Arturo <5713763+maxArturo@users.noreply.github.com>
Co-authored-by: Max Arturo <5713763+maxArturo@users.noreply.github.com>
Co-authored-by: Max Arturo <5713763+maxArturo@users.noreply.github.com>
@colinhacks
Copy link
Owner

colinhacks commented May 9, 2024

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 colinhacks changed the base branch from master to v4 May 9, 2024 22:34
@colinhacks colinhacks changed the title Add ZodTemplateLiteral Add z.literal.template May 9, 2024
@colinhacks
Copy link
Owner

colinhacks commented May 9, 2024

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 colinhacks merged commit 5b532d8 into colinhacks:v4 May 9, 2024
@colinhacks colinhacks changed the title Add z.literal.template Add z.literal.template() May 9, 2024
@igalklebanov igalklebanov deleted the template-literal branch May 10, 2024 13:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[V3] Support template literals
9 participants