question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Mapping readonly array to Zod union

See original GitHub issue

Hi and thanks for Zod. It’s been a joy to use.

Being able to use the following would be even nicer, but it fails for a variety of hard-to-fix reasons…

const items = ["a","b","c"] as const;
const unionSchema = z.union((items.map((item) => z.literal(item)));

I often use an as const array or object to simultaneously define types (guiding the compiler) AND sequences (used at runtime).

For example in the toy case below, the PRIORITIES list defines the type of a Priority string, and can also be iterated to construct a dropdown menu for example. This is a common emerging pattern in typescript codebases. It can ensure that variants are defined once, and that none are missed in mapped types either at compile-time or runtime.

Defining a zod schema from an as const array hits a dead end for me. Perhaps it’s impossible. Perhaps I am missing how to do this properly. If it’s this hard, some kind of utility type could be needed.

Problems

There are an interlocking set of problems…

  • Zod not accepting Readonly definitions meaning the use of as const causes arguments to be rejected.
  • the required arity of a union (at least two) meaning arrays of unknown length are not accepted - normally resolved by using readonly arrays with a known length
  • a weakness of typescript, which can’t keep the tuple nature when using e.g. map()

Solution: Utility Function

If a utility function was provided, I imagine overloaded signatures as follows…

type ZodLiteralTuple<T extends readonly Primitive[]> = {
  readonly [K in keyof T]: ZodLiteral<T[K]>;
};

function createUnionSchema<T extends readonly []>(values: T): ZodNever;
function createUnionSchema<T extends readonly [Primitive]>(
  values: T
): ZodLiteral<T[0]>;
function createUnionSchema<
  T extends readonly [Primitive, Primitive, ...Primitive[]]
>(values: T): z.ZodUnion<ZodLiteralTuple<T>>;

Here, ZodLiteralTuple is a type to use as a type assertion after Array.map. It preserves tuple arity and projects each concrete literal found in T into a corresponding ZodUnionOptions datastructure.

image

CASES

  • createUnionSchema([]) should resolve to z.never()
  • createUnionSchema(["single"]) should return z.literal("single")
  • createUnionSchema(["first","second"]) should return z.union([z.literal("first"), z.literal("second")])
  • createUnionSchema(["first","second","third"]) should return z.union([z.literal("first"), z.literal("second"), , z.literal("third")])

Solution: Adding Readonly

Allowing the Readonly signature for arrays passed into all Zod functions would address one element of the issue. I can raise this as a PR.

Reference Example

The toy example below is based on an experimental todo example. Here, keys for an index are a tuple,: task priority first, task due date second. Endpoint arguments will eventually need to validate the keys passed when paging the index, so we would like to have a schema for priority (a union of priorities), and compose it with a schema for date.

Each attempt hits some limitation, shown as a compiler error.

FIRST ATTEMPT - DERIVE FROM as const TUPLE OF STRINGS

Here, because of a weakness preserving tuple nature, PRIORITY_SCHEMAS has an unknown length, so fails the union arity check.

import { z } from "zod";

type Priority = typeof PRIORITIES[number];

const PRIORITIES = ["urgent", "soon", "normal", "backlog", "wishlist"] as const;

const DATE_SCHEMA = z.number();

/** Compiler error due to arity:
Argument of type 'ZodLiteral<"urgent" | "soon" | "normal" | "backlog" | "wishlist">[]' is not assignable to parameter of type '[ZodTypeAny, ZodTypeAny, ...ZodTypeAny[]]
*/
const PRIORITY_SCHEMAS = z.union(PRIORITIES.map((priority) => z.literal(priority)))

const KEY_SCHEMA = z.tuple([PRIORITY_SCHEMAS, DATE_SCHEMA])

SECOND ATTEMPT - DERIVE FROM TUPLE OF SCHEMAS

Here, because of z.union() only accepting writeable arrays, it rejects a tuple defined with as const (as const ensures its length is known).

import { z } from "zod";

type Priority = typeof PRIORITIES[number];

const PRIORITY_SCHEMAS = [
  z.literal("urgent"),
  z.literal("soon"),
  z.literal("normal"),
  z.literal("backlog"),
  z.literal("wishlist"),
] as const;

const PRIORITIES = PRIORITY_SCHEMAS.map((schema) => schema.value);

const DATE_SCHEMA = z.number();

/** Compiler error:  The type 'readonly [ZodLiteral<"urgent">, ZodLiteral<"soon">, ZodLiteral<"normal">, ZodLiteral<"backlog">, ZodLiteral<"wishlist">]' is 'readonly' and cannot be assigned to the mutable type '[ZodTypeAny, ZodTypeAny, ...ZodTypeAny[]]' */
const PRIORITY_SCHEMA = z.union(PRIORITY_SCHEMAS);

const KEY_SCHEMA = z.tuple([PRIORITY_SCHEMA, DATE_SCHEMA]);

Issue Analytics

  • State:closed
  • Created 2 years ago
  • Reactions:6
  • Comments:15 (8 by maintainers)

github_iconTop GitHub Comments

4reactions
cefncommented, Dec 19, 2021

So I have a working example of a union with overloads for empty, singleton and many against the unmodified zod. It removes the readonly flag from the MappedType so it’s compatible with the writeable array declarations, although I think readonly is a better definition for the future it is less of a blocker for me, now.

import { Primitive, z, ZodLiteral, ZodNever } from "zod";

type MappedZodLiterals<T extends readonly Primitive[]> = {
  -readonly [K in keyof T]: ZodLiteral<T[K]>;
};

function createManyUnion<
  A extends Readonly<[Primitive, Primitive, ...Primitive[]]>
>(literals: A) {
  return z.union(
    literals.map((value) => z.literal(value)) as MappedZodLiterals<A>
  );
}

function createUnionSchema<T extends readonly []>(values: T): ZodNever;
function createUnionSchema<T extends readonly [Primitive]>(
  values: T
): ZodLiteral<T[0]>;
function createUnionSchema<
  T extends readonly [Primitive, Primitive, ...Primitive[]]
>(values: T): z.ZodUnion<MappedZodLiterals<T>>;
function createUnionSchema<T extends readonly Primitive[]>(values: T) {
  if (values.length > 1) {
    return createManyUnion(
      values as typeof values & [Primitive, Primitive, ...Primitive[]]
    );
  } else if (values.length === 1) {
    return z.literal(values[0]);
  } else if (values.length === 0) {
    return z.never();
  }
  throw new Error("Array must have a length");
}

// EXAMPLES

const emptySchema = createUnionSchema([] as const);
const singletonSchema = createUnionSchema(["a"] as const);
const manySchema = createUnionSchema(["a", "b", "c"] as const);

type EmptyType = z.infer<typeof emptySchema>;
type SingletonType = z.infer<typeof singletonSchema>;
type ManyType = z.infer<typeof manySchema>;

3reactions
cefncommented, Dec 23, 2021

Note I found that z.enum() directly fulfils this pattern for non-empty arrays of strings, which is my most common case, but I had passed over it, believing that related to Typescript enums (those are actually handled by z.nativeEnum().

So z.enum() can handle ["a"] as const, ["a","b"] as const and so on.

It can’t handle [] as const like the above implementation It can’t handle [3,4,5] as const like the above implementation can. It can’t handle [3,true,"hello"] as const like the above implementation can.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Zod: create a schema using an existing type - Stack Overflow
If axios exported an array containing the methods, ... of the larger union type defined in Method , the resulting schema is also...
Read more >
Untitled
What is Zod Zod is a TypeScript-first schema declaration and validation ... exactly 5 items z.array(z.string()).length(5); ``` ## Unions Zod includes a ...
Read more >
zod - npm
First, you can create an array schema with the z.array() function; it accepts another ZodSchema, which defines the type of each array element....
Read more >
TypeScript-First Schema Validation with Static Type Inference
ZodSchema <Json> = z.lazy(() => z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]) ); jsonSchema.parse(data);.
Read more >
Understanding the discriminated union pattern
What the discriminated union pattern is and how we can use it to narrow the type of a variable.
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found