@cerios/openapi-to-zod 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,7 +19,7 @@ Transform OpenAPI YAML specifications into Zod v4 compliant schemas with full Ty
19
19
  - 📊 **Statistics**: Optional generation statistics in output files
20
20
  - ❗ **Better Errors**: Clear error messages with file paths and line numbers
21
21
  - 🎭 **Tuple Validation**: OpenAPI 3.1 `prefixItems` support with `.tuple()` and `.rest()`
22
- - 🔗 **Smart AllOf**: Uses `.merge()` for objects, `.and()` for primitives
22
+ - 🔗 **Smart AllOf**: Uses `.extend()` for objects (Zod v4), `.and()` for primitives
23
23
  - 🎯 **Literal Types**: `const` keyword support with `z.literal()`
24
24
  - 🔢 **Exclusive Bounds**: `exclusiveMinimum`/`exclusiveMaximum` with `.gt()`/`.lt()`
25
25
  - 🎨 **Unique Arrays**: `uniqueItems` validation with Set-based checking
@@ -181,9 +181,11 @@ Examples:
181
181
  | `name` | `string` | Optional identifier for logging |
182
182
  | `input` | `string` | Input OpenAPI YAML file path (required) |
183
183
  | `output` | `string` | Output TypeScript file path (required) |
184
- | `mode` | `"strict"` \| `"normal"` \| `"loose"` | Validation mode |
184
+ | `mode` | `"strict"` \| `"normal"` \| `"loose"` | Validation mode for top-level schemas (default: `"normal"`) |
185
+ | `emptyObjectBehavior` | `"strict"` \| `"loose"` \| `"record"` | How to handle empty objects (default: `"loose"`) |
185
186
  | `includeDescriptions` | `boolean` | Include JSDoc comments |
186
187
  | `useDescribe` | `boolean` | Add `.describe()` calls |
188
+ | `defaultNullable` | `boolean` | Treat properties as nullable by default when not explicitly specified (default: `false`) |
187
189
  | `schemaType` | `"all"` \| `"request"` \| `"response"` | Schema filtering |
188
190
  | `prefix` | `string` | Prefix for schema names |
189
191
  | `suffix` | `string` | Suffix for schema names |
@@ -313,6 +315,43 @@ const userSchema = z.looseObject({
313
315
  });
314
316
  ```
315
317
 
318
+ ## Empty Object Behavior
319
+
320
+ When OpenAPI schemas define an object without any properties (e.g., `type: object` with no `properties`), the generator needs to decide how to represent it. The `emptyObjectBehavior` option controls this:
321
+
322
+ ### Loose (default)
323
+ Uses `z.looseObject({})` which allows any additional properties:
324
+
325
+ ```typescript
326
+ // OpenAPI: { type: object }
327
+ const metadataSchema = z.looseObject({});
328
+
329
+ // Accepts: {}, { foo: "bar" }, { any: "properties" }
330
+ ```
331
+
332
+ ### Strict
333
+ Uses `z.strictObject({})` which rejects any properties:
334
+
335
+ ```typescript
336
+ // OpenAPI: { type: object }
337
+ const emptySchema = z.strictObject({});
338
+
339
+ // Accepts: {}
340
+ // Rejects: { foo: "bar" }
341
+ ```
342
+
343
+ ### Record
344
+ Uses `z.record(z.string(), z.unknown())` which treats it as an arbitrary key-value map:
345
+
346
+ ```typescript
347
+ // OpenAPI: { type: object }
348
+ const mapSchema = z.record(z.string(), z.unknown());
349
+
350
+ // Accepts: {}, { foo: "bar" }, { any: "properties" }
351
+ ```
352
+
353
+ > **Note:** The `mode` option controls how top-level schema definitions are wrapped, while `emptyObjectBehavior` controls how nested empty objects (properties without defined structure) are generated. These are independent settings.
354
+
316
355
  ## Examples
317
356
 
318
357
  ### Input OpenAPI YAML
@@ -410,6 +449,68 @@ The generator supports all OpenAPI string formats with Zod v4:
410
449
  | `cidrv4` | `z.cidrv4()` |
411
450
  | `cidrv6` | `z.cidrv6()` |
412
451
 
452
+ ### Custom Date-Time Format
453
+
454
+ By default, the generator uses `z.iso.datetime()` for `date-time` format fields, which requires an ISO 8601 datetime string with a timezone suffix (e.g., `2026-01-07T14:30:00Z`).
455
+
456
+ If your API returns date-times **without the `Z` suffix** (e.g., `2026-01-07T14:30:00`), you can override this with a custom regex pattern:
457
+
458
+ ```typescript
459
+ import { defineConfig } from '@cerios/openapi-to-zod';
460
+
461
+ export default defineConfig({
462
+ defaults: {
463
+ // For date-times without Z suffix
464
+ customDateTimeFormatRegex: '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}$',
465
+ },
466
+ specs: [
467
+ {
468
+ input: 'openapi.yaml',
469
+ output: 'src/schemas.ts',
470
+ },
471
+ ],
472
+ });
473
+ ```
474
+
475
+ **TypeScript Config - RegExp Literals:**
476
+
477
+ In TypeScript config files, you can also use RegExp literals (which don't require double-escaping):
478
+
479
+ ```typescript
480
+ export default defineConfig({
481
+ defaults: {
482
+ // Use RegExp literal (single escaping)
483
+ customDateTimeFormatRegex: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/,
484
+ },
485
+ specs: [
486
+ {
487
+ input: 'openapi.yaml',
488
+ output: 'src/schemas.ts',
489
+ },
490
+ ],
491
+ });
492
+ ```
493
+
494
+ **Common Custom Formats:**
495
+
496
+ | Use Case | String Pattern (JSON/YAML) | RegExp Literal (TypeScript) |
497
+ |----------|----------------------------|----------------------------|
498
+ | No timezone suffix<br>`2026-01-07T14:30:00` | `'^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}$'` | `/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/` |
499
+ | With milliseconds, no Z<br>`2026-01-07T14:30:00.123` | `'^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}$'` | `/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$/` |
500
+ | Optional Z suffix<br>`2026-01-07T14:30:00` or<br>`2026-01-07T14:30:00Z` | `'^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z?$'` | `/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?$/` |
501
+ | With milliseconds + optional Z<br>`2026-01-07T14:30:00.123Z` | `'^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z?$'` | `/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z?$/` |
502
+
503
+ **Generated Output:**
504
+
505
+ When using a custom regex, the generator will produce:
506
+
507
+ ```typescript
508
+ // Instead of: z.iso.datetime()
509
+ // You get: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/)
510
+ ```
511
+
512
+ **Note:** This option only affects `date-time` format fields. Other formats (like `date`, `email`, `uuid`) remain unchanged.
513
+
413
514
  ## Advanced Features
414
515
 
415
516
  ### Operation Filtering
@@ -563,9 +664,90 @@ export const userSchema = z.object({
563
664
 
564
665
  OpenAPI's `nullable: true` is converted to `.nullable()`
565
666
 
667
+ #### Default Nullable Behavior
668
+
669
+ By default, properties are only nullable when explicitly marked with `nullable: true` (OpenAPI 3.0) or `type: ["string", "null"]` (OpenAPI 3.1).
670
+
671
+ However, many teams follow the industry de facto standard for OpenAPI 3.0.x where properties are assumed nullable unless explicitly constrained. You can enable this behavior with the `defaultNullable` option:
672
+
673
+ ```typescript
674
+ export default defineConfig({
675
+ specs: [{
676
+ input: 'openapi.yaml',
677
+ output: 'schemas.ts',
678
+ defaultNullable: true, // Treat unspecified properties as nullable
679
+ }]
680
+ });
681
+ ```
682
+
683
+ **Important:** `defaultNullable` only applies to **primitive property values** within objects. It does NOT apply to:
684
+
685
+ - **Top-level schema definitions** - Schemas are not made nullable at the definition level
686
+ - **Schema references (`$ref`)** - References preserve the nullability of the target schema; add explicit `nullable: true` if needed
687
+ - **Enum values** - Enums define discrete values and are not nullable by default
688
+ - **Const/literal values** - Literals are exact values and are not nullable by default
689
+
690
+ **Behavior comparison:**
691
+
692
+ | Schema Property | `defaultNullable: false` (default) | `defaultNullable: true` |
693
+ |-----------------|-------------------------------------|-------------------------|
694
+ | `nullable: true` | `.nullable()` | `.nullable()` |
695
+ | `nullable: false` | No `.nullable()` | No `.nullable()` |
696
+ | No annotation (primitive) | No `.nullable()` | `.nullable()` |
697
+ | No annotation (`$ref`) | No `.nullable()` | No `.nullable()` |
698
+ | No annotation (enum) | No `.nullable()` | No `.nullable()` |
699
+ | No annotation (const) | No `.nullable()` | No `.nullable()` |
700
+
701
+ **Example:**
702
+
703
+ ```yaml
704
+ components:
705
+ schemas:
706
+ Status:
707
+ type: string
708
+ enum: [active, inactive]
709
+ User:
710
+ type: object
711
+ properties:
712
+ id:
713
+ type: integer
714
+ name:
715
+ type: string
716
+ status:
717
+ $ref: '#/components/schemas/Status'
718
+ nullableStatus:
719
+ allOf:
720
+ - $ref: '#/components/schemas/Status'
721
+ nullable: true
722
+ ```
723
+
724
+ **With `defaultNullable: false` (default):**
725
+ ```typescript
726
+ export const statusSchema = z.enum(["active", "inactive"]);
727
+
728
+ export const userSchema = z.object({
729
+ id: z.number().int(),
730
+ name: z.string(), // Not nullable (no annotation)
731
+ status: statusSchema, // Not nullable ($ref)
732
+ nullableStatus: statusSchema.nullable(), // Explicitly nullable
733
+ });
734
+ ```
735
+
736
+ **With `defaultNullable: true`:**
737
+ ```typescript
738
+ export const statusSchema = z.enum(["active", "inactive"]);
739
+
740
+ export const userSchema = z.object({
741
+ id: z.number().int().nullable(), // Nullable (primitive)
742
+ name: z.string().nullable(), // Nullable (primitive)
743
+ status: statusSchema, // NOT nullable ($ref - must be explicit)
744
+ nullableStatus: statusSchema.nullable(), // Explicitly nullable
745
+ });
746
+ ```
747
+
566
748
  ### Schema Composition
567
749
 
568
- - `allOf` → `.merge()` for objects, `.and()` for primitives
750
+ - `allOf` → `.extend()` for objects (Zod v4), `.and()` for primitives
569
751
  - `oneOf`, `anyOf` → `z.union()` or `z.discriminatedUnion()`
570
752
  - `$ref` → Proper schema references
571
753
 
@@ -1015,11 +1197,11 @@ export const flexibleMetadataSchema = z
1015
1197
 
1016
1198
  ### Schema Composition
1017
1199
 
1018
- #### AllOf - Smart Merging
1200
+ #### AllOf - Smart Extending
1019
1201
 
1020
- Uses `.merge()` for objects, `.and()` for primitives:
1202
+ Uses `.extend()` for objects (Zod v4 compliant - `.merge()` is deprecated), `.and()` for primitives:
1021
1203
 
1022
- **Object Merging:**
1204
+ **Object Extending:**
1023
1205
  ```yaml
1024
1206
  User:
1025
1207
  allOf:
@@ -1036,10 +1218,10 @@ User:
1036
1218
  **Generated:**
1037
1219
  ```typescript
1038
1220
  export const userSchema = baseEntitySchema
1039
- .merge(timestampedSchema)
1040
- .merge(z.object({
1221
+ .extend(timestampedSchema.shape)
1222
+ .extend(z.object({
1041
1223
  username: z.string()
1042
- }));
1224
+ }).shape);
1043
1225
  ```
1044
1226
 
1045
1227
  #### OneOf / AnyOf
@@ -1161,7 +1343,7 @@ export const statusCodeSchema = z.enum(["200", "201", "400", "404", "500"]);
1161
1343
  | const | ✅ | ✅ | `z.literal()` |
1162
1344
  | nullable (property) | ✅ | ✅ | `.nullable()` |
1163
1345
  | nullable (type array) | ❌ | ✅ | `.nullable()` |
1164
- | allOf (objects) | ✅ | ✅ | `.merge()` |
1346
+ | allOf (objects) | ✅ | ✅ | `.extend()` |
1165
1347
  | allOf (primitives) | ✅ | ✅ | `.and()` |
1166
1348
  | oneOf/anyOf | ✅ | ✅ | `z.union()` |
1167
1349
  | discriminators | ✅ | ✅ | `z.discriminatedUnion()` |