| | |

Zod 10 🛡️ Arrays and Tuples — z.array() and fixed-length structures

⏱️ Estimated read: 10 min | |

Arrays and tuples are Zod’s collection schemas. z.array(schema) validates that every element matches the same schema, and infers T[]. z.tuple([a, b]) validates a fixed-length sequence where each position has its own schema, and infers a positional tuple type like [string, number]. The distinction is structural: arrays are homogeneous and variable-length; tuples are heterogeneous and fixed-length. Both preserve the schema’s type through inference, and both compose with the object and primitive schemas from earlier chapters.

This chapter covers array constraints, tuple construction, the rest argument for variable-length tuples in v4, and the composition patterns that combine arrays with objects. It explains why z.array() alone accepts an empty array, how .nonempty() and length constraints narrow acceptance without changing the inferred type, and why tuples are the correct schema for fixed-position data like coordinates or function arguments. The sections that follow walk through arrays, tuples, and the composition patterns that use both.

Key point: z.array(schema) validates a homogeneous list of any length; z.tuple([a, b]) validates exactly those positions with those types, and v4 adds a rest argument for tuples that extend beyond the fixed prefix.


Why arrays and tuples exist

The empty-array problem. z.array(z.string()) accepts []. That is correct for a list that may legitimately be empty, but wrong for a list that must contain at least one item. The .nonempty() method narrows the schema to reject empty arrays, and .min(n) enforces a minimum length . Without these constraints, a schema that should reject an empty list passes validation and the empty list reaches code that assumes it has elements.

The positional-data problem. Some data is positional and heterogeneous: a coordinate is [number, number], a range is [start, end], and a function’s arguments are an ordered list of different types. z.array(z.union([...])) loses the positional information; it validates that every element is a number or a string, not that the first is a string and the second is a number. z.tuple([z.string(), z.number()]) preserves the positions and infers [string, number] . The tuple is the correct schema when position carries meaning.

The rest-argument problem. A tuple can have a fixed prefix and a variable-length tail. v4 adds a rest argument to z.tuple(): z.tuple([z.string()], z.string()) infers [string, ...string[]], a tuple with one required string followed by any number of strings . This aligns with TypeScript’s tuple spread syntax and replaces the less expressive union-of-arrays workaround.


a. Arrays and length constraints

z.array(schema) validates every element against the schema and infers T[]. The base schema accepts any length, including zero. Constraints narrow the accepted length without changing the inferred type.

import { z } from "zod";

// Base array: any length, homogeneous
const TagsSchema = z.array(z.string());     // string[]

// Non-empty: rejects []
const NonEmptyTags = z.array(z.string()).nonempty(); // string[]

// Minimum length
const MinTwo = z.array(z.string()).min(2);  // string[]

// Maximum length
const MaxTen = z.array(z.string()).max(10); // string[]

// Exact length
const Pair = z.array(z.string()).length(2); // string[]

The .nonempty() method is a convenience for .min(1) with a clearer intent . The inferred type remains T[]; TypeScript cannot express “non-empty array” as a distinct type without a branded type, so the constraint is runtime-only. A .min(2) constraint on an array of strings still infers string[], not [string, string].

Arrays compose with objects. A field whose value is an array of objects is z.array(ObjectSchema), and the inferred type is ObjectType[].

const ItemSchema = z.object({ sku: z.string(), qty: z.number() });
const OrderSchema = z.object({
  items: z.array(ItemSchema).nonempty(),
});
// { items: { sku: string; qty: number }[] }

b. Tuples and positional types

z.tuple([schema1, schema2]) validates that the input is an array of exactly that length, with each position matching its schema. The inferred type is a TypeScript tuple, preserving the position-to-type mapping.

// A coordinate: exactly two numbers
const PointSchema = z.tuple([z.number(), z.number()]);
// [number, number]

// A mixed tuple: string, then number, then boolean
const EntrySchema = z.tuple([z.string(), z.number(), z.boolean()]);
// [string, number, boolean]

// Parse
PointSchema.parse([1, 2]);      // valid
PointSchema.parse([1, 2, 3]);   // ZodError: too many elements
PointSchema.parse([1]);         // ZodError: too few elements
PointSchema.parse(["a", 2]);    // ZodError: first element not number

Tuples are strict about length. A tuple schema with two positions rejects arrays with one or three elements. This is the correct behavior for positional data, and it is the reason a tuple cannot be used where the length varies.

v4 adds a rest argument for variable-length tails. The first argument is the fixed prefix; the second is the schema for every element after the prefix.

// One required string, then any number of strings
const PrefixedList = z.tuple([z.string()], z.string());
// [string, ...string[]]

PrefixedList.parse(["head"]);              // valid
PrefixedList.parse(["head", "a", "b"]);    // valid
PrefixedList.parse([]);                    // ZodError: missing first

The rest argument replaces the v3 pattern of z.union([z.tuple([z.string()]), z.array(z.string())]), which could not express the same type .


c. Composing arrays and tuples with objects

Arrays and tuples are most useful when they appear as fields inside object schemas. The object provides the named structure; the array or tuple provides the collection.

// Array of objects as a field
const TeamSchema = z.object({
  name: z.string(),
  members: z.array(z.object({
    id: z.string(),
    role: z.enum(["admin", "member"]),
  })).nonempty(),
});

// Tuple as a field: a fixed-format coordinate pair
const LocationSchema = z.object({
  label: z.string(),
  coordinates: z.tuple([z.number(), z.number()]),
});

The inferred types nest automatically. TeamSchema infers an object with a members field of type { id: string; role: "admin" | "member" }[]. LocationSchema infers a coordinates field of type [number, number].

A tuple can also represent a fixed-length record that could have been an object. The choice is semantic: an object names its fields and is self-documenting; a tuple is positional and shorter to write. Use a tuple when the positions are conventional and the names would add no information, as with coordinates, ranges, and key-value pairs where the key is always first.


Complete Example Session

// ============================================
// PART 1: ARRAY WITH CONSTRAINTS
// ============================================
import { z } from "zod";

const TagsSchema = z.array(z.string()).nonempty().max(10);
// string[], runtime: at least 1, at most 10

// ============================================
// PART 2: ARRAY OF OBJECTS
// ============================================
const ProductSchema = z.object({
  name: z.string(),
  variants: z.array(z.object({
    sku: z.string(),
    price: z.number().positive(),
  })).nonempty(),
});

// ============================================
// PART 3: FIXED TUPLE
// ============================================
const PointSchema = z.tuple([z.number(), z.number()]);
// [number, number]

// ============================================
// PART 4: MIXED TUPLE
// ============================================
const RangeSchema = z.tuple([z.number(), z.number(), z.string()]);
// [number, number, string] — e.g. start, end, unit

// ============================================
// PART 5: TUPLE WITH REST (v4)
// ============================================
const CommandSchema = z.tuple([z.string()], z.string());
// [string, ...string[]] — command name, then arguments

// ============================================
// PART 6: INFERRED TYPES
// ============================================
type Tags = z.infer<typeof TagsSchema>;         // string[]
type Product = z.infer<typeof ProductSchema>;   // { name: string; variants: ...[] }
type Point = z.infer<typeof PointSchema>;       // [number, number]
type Range = z.infer<typeof RangeSchema>;       // [number, number, string]
type Command = z.infer<typeof CommandSchema>;   // [string, ...string[]]

The parts covered array constraints, arrays of objects, fixed tuples, mixed tuples, the v4 rest argument, and the inferred types for each.


Quick Reference

Array Schemas

SchemaInferred TypeAccepts
z.array(z.string())string[]Any length, all strings
.nonempty()string[]Length ≥ 1
.min(n)string[]Length ≥ n
.max(n)string[]Length ≤ n
.length(n)string[]Length = n

Tuple Schemas

SchemaInferred TypeAccepts
z.tuple([a, b])[A, B]Exactly 2 elements
z.tuple([a, b, c])[A, B, C]Exactly 3 elements
z.tuple([a], rest)[A, ...Rest[]]1+ elements

Array vs Tuple

AspectArrayTuple
LengthVariableFixed (or fixed prefix)
Element typesHomogeneousPositional
Inferred typeT[][A, B]
Use forLists, collectionsCoordinates, arguments

Best Practices

✅ Do This:

// Reject empty lists that must have items
z.array(z.string()).nonempty()                               // ✅

// Use tuple for positional data
z.tuple([z.number(), z.number()])                            // ✅

// Use rest argument for variable tails (v4)
z.tuple([z.string()], z.string())                            // ✅

// Nest arrays inside objects
z.object({ items: z.array(ItemSchema) })                     // ✅

❌ Don’t Do This:

// Assume z.array() rejects empty
z.array(z.string()).parse([]) // passes                        // ❌

// Use array of union for positional data
z.array(z.union([z.string(), z.number()])) // loses position  // ❌

// Use tuple for variable-length homogeneous list
z.tuple([z.string(), z.string()]) // only exactly 2           // ❌

// Expect .min() to change inferred type
type T = z.infer<typeof z.array(z.string()).min(2)>; // string[] // ❌ (still string[])

Common Pitfalls

PitfallWhy It HappensFix
Empty array passesz.array() accepts any lengthAdd .nonempty() or .min(1)
Tuple length mismatchFixed positions enforcedCheck input length
Positional data type lostArray of union usedUse z.tuple()
Rest argument not recognizedv3 parser or typeUse v4 syntax

Real-World Examples

1. Non-empty string list

z.array(z.string()).nonempty();

2. Fixed pair

z.tuple([z.number(), z.number()]);

3. Mixed tuple

z.tuple([z.string(), z.number(), z.boolean()]);

4. Tuple with rest

z.tuple([z.string()], z.string());

5. Array of objects

z.array(z.object({ id: z.string() }));

6. Array with max

z.array(z.string()).max(10);

7. Exact-length array

z.array(z.string()).length(2);

8. Coordinate field

z.object({ lat: z.number(), lng: z.number() }); // or tuple

9. Command arguments

z.tuple([z.string()], z.string());

10. Nested array

z.array(z.array(z.number()));

Visual

Array vs Tuple

┌─────────────────────────────────────────────────────────┐
│  z.array(z.string())                                    │
│  ["a", "b", "c"]    ✓                                   │
│  ["a"]              ✓                                   │
│  []                 ✓ (unless .nonempty())              │
│  [1, 2]             ✗ (numbers)                         │
│                                                         │
│  z.tuple([z.string(), z.number()])                      │
│  ["a", 1]           ✓                                   │
│  ["a", 1, 2]        ✗ (too long)                        │
│  ["a"]              ✗ (too short)                       │
│  [1, "a"]           ✗ (wrong positions)                 │
└─────────────────────────────────────────────────────────┘

Tuple Rest Argument (v4)

┌─────────────────────────────────────────────────────────┐
│  z.tuple([z.string()], z.string())                      │
│                                                         │
│  Fixed prefix: first element must be string             │
│  Rest: every element after must be string               │
│                                                         │
│  ["cmd"]                    ✓                           │
│  ["cmd", "arg1"]            ✓                           │
│  ["cmd", "arg1", "arg2"]    ✓                           │
│  []                         ✗ (missing prefix)          │
│  [1, "arg"]                 ✗ (prefix not string)       │
│                                                         │
│  Inferred: [string, ...string[]]                        │
└─────────────────────────────────────────────────────────┘

Array Constraints

┌─────────────────────────────────────────────────────────┐
│  z.array(z.string())                                    │
│         │                                               │
│         ├── .nonempty()  → length >= 1                  │
│         ├── .min(2)      → length >= 2                  │
│         ├── .max(10)     → length <= 10                 │
│         └── .length(3)   → length == 3                  │
│                                                         │
│  Inferred type: string[] (unchanged)                    │
└─────────────────────────────────────────────────────────┘

Summary

ItemValue
Array schemaz.array(schema) → T[]
Non-empty.nonempty() → rejects []
Length constraints.min(), .max(), .length()
Tuple schemaz.tuple([a, b]) → [A, B]
Tuple rest (v4)z.tuple([a], rest) → [A, ...Rest[]]
Positional dataTuple preserves positions
Homogeneous listsArray validates all elements

Key takeaways:

  • z.array() accepts the empty array. A list that must contain at least one item needs .nonempty() or .min(1). The constraint is runtime-only; the inferred type remains T[].
  • Tuples are fixed-length and positional. z.tuple([z.string(), z.number()]) validates exactly two elements with the first a string and the second a number, and infers [string, number].
  • v4 adds a rest argument for variable tails. z.tuple([z.string()], z.string()) expresses “one required string followed by any number of strings” and infers [string, ...string[]] .
  • Arrays and tuples compose with objects. The object names the field; the array or tuple defines its collection semantics. The inferred type nests automatically.
  • Use a tuple when position carries meaning. Coordinates, ranges, and command arguments are positional; a list of tags is not. The schema should reflect which kind of data it is validating.

Remember: An array is a list; a tuple is a record with positions. The array schema answers “what type is every element?” and the tuple schema answers “what type is each position?” The choice is determined by the data, not by convenience: if reordering the elements would change the meaning, it is a tuple; if the elements are interchangeable, it is an array. When an empty list should be rejected, say so with .nonempty(). When the tail length varies, use the rest argument rather than a union of arrays.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!