When building TypeScript applications with Cucumber for testing, a common challenge emerges: handling empty cells in Cucumber DataTables. These empty cells are parsed as empty strings (''
) in JavaScript, which can lead to subtle bugs when using Zod schemas, particularly when trying to make fields optional or provide default values.
ℹ️ Disclaimer: This is highly specific to scenarios where you need to create a good DX within the confines of your tool. (i.e. Gherkin + Cucumber)
The Problem: Empty Strings in Cucumber DataTables
Consider this common scenario in Cucumber testing:
Given the following product configurations:
| Product Name | Status | DefaultColor |
| "Alpha" | "ACTIVE" | "RED" |
| "Bravo" | "INACTIVE"| |
| "Charlie" | "ACTIVE" | "BLUE" |
| "Delta" | | "GREEN" |
When Cucumber processes this DataTable, empty cells (like the Status
for "Delta" and the DefaultColor
for "Bravo") are parsed by Cucumber into JS objects with properties set to empty strings (''
), not as undefined
or null
. This creates a subtle but important challenge when using Zod schemas.
The Trap: Chaining .optional()
or .default()
After z.preprocess
A common approach might be to create a helper function that uses z.preprocess
to handle these empty strings:
function enumValueFromKey_v1<T extends z.EnumLike>(enumType: T) {
const keySchema = z.enum(Object.keys(enumType) as [string, ...string[]]);
const baseSchema = keySchema.transform(key => enumType[key]);
return z.preprocess((input) => {
if (input === '') return undefined;
return input;
}, baseSchema);
}
// Later, in your code:
const myEnum = { ACTIVE: 'ACTIVE', INACTIVE: 'INACTIVE' } as const;
const schema = enumValueFromKey_v1(myEnum).optional(); // DANGER! This might not work as expected!
// What happens with an empty string input?
schema.parse(''); // This will throw, even though we used .optional()!
The problem here is subtle but critical. When you chain .optional()
after the z.preprocess
call, you're modifying the wrong layer of the schema. Let's trace what happens:
- Input
''
arrives at the schema - The
z.preprocess
function runs first, converting''
toundefined
- This
undefined
is passed tobaseSchema
, which might throw because it's not optional! - Even though we chained
.optional()
, it's too late - the error already happened inside thepreprocess
pipeline
The Solution: Callback Pattern for Schema Refinement
To solve this, we need to allow schema refinements (like .optional()
or .default()
) to be applied on the schema given to the preprocess
step. This is where the callback pattern comes in:
function enumValueFromKey_v2<T extends z.EnumLike>(
enumType: T,
cb?: (schema: z.ZodType) => z.ZodType // Optional callback for refinements
) {
const keySchema = z.enum(Object.keys(enumType) as [string, ...string[]]);
const baseSchema = keySchema.transform(key => enumType[key]);
// Apply user's refinements on schema passed to preprocess
const refinedSchema = cb ? cb(baseSchema) : baseSchema;
return z.preprocess((input) => {
if (input === '') return undefined;
return input;
}, refinedSchema); // preprocess wraps the already-refined schema
}
// Usage:
const schema = enumValueFromKey_v2(myEnum, s => s.optional());
schema.parse(''); // Works! Returns undefined as expected
Now the flow is correct:
- Input
''
arrives -
preprocess
converts it toundefined
- The
undefined
is passed to the schema that is already optional - Everything works as expected!
Type Safety: Preventing Incorrect Usage
While the callback pattern solves the runtime behavior, we still want to prevent developers from accidentally chaining .optional()
or .default()
on the returned schema. This is where TypeScript's type system comes in.
We can use TypeScript's this
type to make direct method calls produce compile-time errors:
/**
* Defines method signatures that use `this: never` to make them uncallable directly
* on an object instance, thereby forcing their use through other mechanisms (like a callback).
* The parameter and return types mirror Zod's standard methods to maintain structural
* compatibility for type assignability when intersected with an actual Zod schema type.
*/
export type ForbiddenMethodOverrides<SchemaInstance extends ZodTypeAny> = {
optional: (this: never) => ZodOptional<SchemaInstance>;
nullable: (this: never) => ZodNullable<SchemaInstance>;
nullish: (this: never) => ZodOptional<ZodNullable<SchemaInstance>>;
default: (
this: never,
value: z.input<SchemaInstance> | (() => z.input<SchemaInstance>),
) => ZodDefault<SchemaInstance>;
// Add other Zod methods here if they should also be forbidden in the same way.
};
// In your helper function:
type NonChainableZodSchema<SchemaInstance extends z.ZodTypeAny> = Omit<
SchemaInstance,
keyof ForbiddenMethodOverrides<SchemaInstance>
> &
ForbiddenMethodOverrides<SchemaInstance>;
// Now when someone tries:
const schema = enumValueFromKey_v2(myEnum).optional(); // TypeScript Error!
// Error: The 'this' context of type 'NonChainableZodSchema<...>'
// is not assignable to method's 'this' of type 'never'.
This type-level protection ensures that developers get immediate feedback if they try to use the helper function incorrectly, with clear error messages guiding them toward the correct callback pattern.
Key Takeaways
- Empty strings from Cucumber DataTables require special handling in Zod schemas
- Chaining
.optional()
or.default()
afterz.preprocess
can lead to subtle bugs - Use the callback pattern to apply schema refinements compatible with
preprocess
- Leverage TypeScript's
this
type to prevent incorrect usage at compile-time - Provide somewhat clear error messages that guide developers toward correct usage patterns. Admittedly the typescript error is not that straightforward. If anyone knows how to improve that I'm all ears.
Top comments (1)
Some comments have been hidden by the post's author - find out more