Mapping readonly array to Zod union
See original GitHub issueHi 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 ofas 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.
CASES
createUnionSchema([])
should resolve toz.never()
createUnionSchema(["single"])
should returnz.literal("single")
createUnionSchema(["first","second"])
should returnz.union([z.literal("first"), z.literal("second")])
createUnionSchema(["first","second","third"])
should returnz.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:
- Created 2 years ago
- Reactions:6
- Comments:15 (8 by maintainers)
Top GitHub Comments
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.
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 byz.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.