Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feature #2210
Added support for "as const satisfies readonly"
  • Loading branch information
sentik committed Nov 4, 2025
commit e82e8ab5ea3edb44cd24e47db8b1d159efd66f7c
38 changes: 30 additions & 8 deletions packages/zod/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,8 @@ export const generateZodValidationSchemaDefinition = (
const arrayItems = value.map((item) =>
isString(item) ? `"${escape(item)}"` : `${item}`,
);
return `${key}: [${arrayItems.join(', ')}]`;
// Add 'as const' for arrays in objects to preserve literal types
return `${key}: [${arrayItems.join(', ')}] as const`;
}

if (
Expand All @@ -323,7 +324,8 @@ export const generateZodValidationSchemaDefinition = (
return `${key}: ${value}`;
})
.join(', ');
defaultValue = `{ ${entries} }`;
// Use 'as const satisfies' for object default values to preserve literal types
defaultValue = `{ ${entries} } as const satisfies Record<string, unknown>`;
defaultType = 'Record<string, unknown>';
} else {
// openapi3-ts's SchemaObject defines default as 'any'
Expand All @@ -338,7 +340,8 @@ export const generateZodValidationSchemaDefinition = (
if (rawStringified === 'null') {
defaultType = 'null';
} else if (Array.isArray(schema.default)) {
defaultType = 'unknown[]';
// Use readonly array type for better TypeScript support
defaultType = 'readonly unknown[]';
} else if (typeof schema.default === 'string') {
defaultType = 'string';
} else if (typeof schema.default === 'number') {
Expand All @@ -347,16 +350,30 @@ export const generateZodValidationSchemaDefinition = (
defaultType = 'boolean';
}

// If the schema is an array with enum items, add 'as const' for proper TypeScript typing
// If the schema is an array with enum items, add 'as const satisfies' for proper TypeScript typing
const isArrayWithEnumItems =
Array.isArray(schema.default) &&
type === 'array' &&
schema.items &&
'enum' in schema.items;

if (isArrayWithEnumItems) {
// Determine if enum items are strings for better type inference
const enumItems = schema.items.enum;
const allEnumItemsAreStrings =
enumItems &&
Array.isArray(enumItems) &&
enumItems.every((v) => isString(v));
if (allEnumItemsAreStrings) {
defaultValue = `${defaultValue} as const satisfies readonly string[]`;
defaultType = 'readonly string[]';
} else {
defaultValue = `${defaultValue} as const satisfies readonly unknown[]`;
defaultType = 'readonly unknown[]';
}
} else if (Array.isArray(schema.default)) {
// Add 'as const' for all arrays to preserve literal types
defaultValue = `${defaultValue} as const`;
defaultType = 'readonly unknown[]';
}
}
consts.push(
Expand Down Expand Up @@ -684,11 +701,15 @@ export const generateZodValidationSchemaDefinition = (

if (schema.enum) {
if (schema.enum.every((value) => isString(value))) {
// Use 'as const satisfies readonly string[]' for better TypeScript type inference
// Add spaces for better readability
functions.push([
'enum',
`[${schema.enum.map((value) => `'${escape(value)}'`).join(', ')}]`,
`[${schema.enum.map((value) => `'${escape(value)}'`).join(', ')}] as const satisfies readonly string[]`,
]);
} else {
// For mixed enum types, use union with literals
// Add spaces for better readability
functions.push([
'oneOf',
schema.enum.map((value) => ({
Expand Down Expand Up @@ -870,7 +891,7 @@ ${Object.entries(mergedProperties)
},
);

return `.union([${union}])`;
return `.union([${union.join(', ')}])`;
}

if (fn === 'additionalProperties') {
Expand Down Expand Up @@ -910,12 +931,13 @@ ${Object.entries(args)
}

if (fn === 'tuple') {
// Add spaces for better readability in tuple definitions
return `zod.tuple([${(args as ZodValidationSchemaDefinition[])
.map((x) => {
const value = x.functions.map((prop) => parseProperty(prop)).join('');
return `${value.startsWith('.') ? 'zod' : ''}${value}`;
})
.join(',\n')}])`;
.join(', ')}])`;
}
if (fn === 'rest') {
return `.rest(zod${(args as ZodValidationSchemaDefinition).functions.map((prop) => parseProperty(prop))})`;
Expand Down
39 changes: 23 additions & 16 deletions packages/zod/src/zod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1081,7 +1081,7 @@ describe('generateZodValidationSchemaDefinition`', () => {
['default', 'testArrayDefaultDefault'],
],
consts: [
'export const testArrayDefaultDefault: unknown[] = ["a", "b"];',
'export const testArrayDefaultDefault: readonly unknown[] = ["a", "b"] as const;',
],
});

Expand All @@ -1096,7 +1096,7 @@ describe('generateZodValidationSchemaDefinition`', () => {
'zod.array(zod.string()).default(testArrayDefaultDefault)',
);
expect(parsed.consts).toBe(
'export const testArrayDefaultDefault: unknown[] = ["a", "b"];',
'export const testArrayDefaultDefault: readonly unknown[] = ["a", "b"] as const;',
);
});

Expand Down Expand Up @@ -1149,7 +1149,7 @@ describe('generateZodValidationSchemaDefinition`', () => {
['default', 'testObjectDefaultDefault'],
],
consts: [
'export const testObjectDefaultDefault: Record<string, unknown> = { name: "Fluffy", age: 3 };',
'export const testObjectDefaultDefault: Record<string, unknown> = { name: "Fluffy", age: 3 } as const satisfies Record<string, unknown>;',
],
});

Expand All @@ -1164,7 +1164,7 @@ describe('generateZodValidationSchemaDefinition`', () => {
'zod.object({\n "name": zod.string().optional(),\n "age": zod.number().optional()\n}).default(testObjectDefaultDefault)',
);
expect(parsed.consts).toBe(
'export const testObjectDefaultDefault: Record<string, unknown> = { name: "Fluffy", age: 3 };',
'export const testObjectDefaultDefault: Record<string, unknown> = { name: "Fluffy", age: 3 } as const satisfies Record<string, unknown>;',
);
});

Expand Down Expand Up @@ -1242,7 +1242,7 @@ describe('generateZodValidationSchemaDefinition`', () => {

expect(result).toEqual({
functions: [
['enum', "['cat', 'dog']"],
['enum', "['cat', 'dog'] as const satisfies readonly string[]"],
['optional', undefined],
],
consts: [],
Expand All @@ -1255,7 +1255,9 @@ describe('generateZodValidationSchemaDefinition`', () => {
false,
false,
);
expect(parsed.zod).toBe("zod.enum(['cat', 'dog']).optional()");
expect(parsed.zod).toBe(
"zod.enum(['cat', 'dog'] as const satisfies readonly string[]).optional()",
);
});

it('generates an enum for a number', () => {
Expand Down Expand Up @@ -1295,7 +1297,7 @@ describe('generateZodValidationSchemaDefinition`', () => {
false,
);
expect(parsed.zod).toBe(
'zod.union([zod.literal(1),zod.literal(2)]).optional()',
'zod.union([zod.literal(1), zod.literal(2)]).optional()',
);
});

Expand Down Expand Up @@ -1336,7 +1338,7 @@ describe('generateZodValidationSchemaDefinition`', () => {
false,
);
expect(parsed.zod).toBe(
'zod.union([zod.literal(true),zod.literal(false)]).optional()',
'zod.union([zod.literal(true), zod.literal(false)]).optional()',
);
});

Expand Down Expand Up @@ -1410,7 +1412,7 @@ describe('generateZodValidationSchemaDefinition`', () => {
false,
);
expect(parsed.zod).toBe(
"zod.union([zod.literal('cat'),zod.literal(1),zod.literal(true)]).optional()",
"zod.union([zod.literal('cat'), zod.literal(1), zod.literal(true)]).optional()",
);
});

Expand Down Expand Up @@ -1438,14 +1440,19 @@ describe('generateZodValidationSchemaDefinition`', () => {
[
'array',
{
functions: [['enum', "['A', 'B', 'C']"]],
functions: [
[
'enum',
"['A', 'B', 'C'] as const satisfies readonly string[]",
],
],
consts: [],
},
],
['default', 'testEnumArrayDefaultDefault'],
],
consts: [
'export const testEnumArrayDefaultDefault: readonly unknown[] = ["A"] as const;',
'export const testEnumArrayDefaultDefault: readonly string[] = ["A"] as const satisfies readonly string[];',
],
});

Expand All @@ -1457,10 +1464,10 @@ describe('generateZodValidationSchemaDefinition`', () => {
false,
);
expect(parsed.zod).toBe(
"zod.array(zod.enum(['A', 'B', 'C'])).default(testEnumArrayDefaultDefault)",
"zod.array(zod.enum(['A', 'B', 'C'] as const satisfies readonly string[])).default(testEnumArrayDefaultDefault)",
);
expect(parsed.consts).toBe(
'export const testEnumArrayDefaultDefault: readonly unknown[] = ["A"] as const;',
'export const testEnumArrayDefaultDefault: readonly string[] = ["A"] as const satisfies readonly string[];',
);
});

Expand Down Expand Up @@ -2692,7 +2699,7 @@ describe('generateZodWithMultiTypeArray', () => {

// Should create a union of string, number, and boolean, with nullable
expect(parsed.zod).toBe(
'zod.union([zod.string(),zod.number(),zod.boolean()]).nullable()',
'zod.union([zod.string(), zod.number(), zod.boolean()]).nullable()',
);
});

Expand Down Expand Up @@ -2726,7 +2733,7 @@ describe('generateZodWithMultiTypeArray', () => {
false,
);

expect(parsed.zod).toBe('zod.union([zod.string(),zod.number()])');
expect(parsed.zod).toBe('zod.union([zod.string(), zod.number()])');
});

it('handles multi-type arrays with optional', () => {
Expand Down Expand Up @@ -2760,7 +2767,7 @@ describe('generateZodWithMultiTypeArray', () => {
);

expect(parsed.zod).toBe(
'zod.union([zod.string(),zod.number()]).optional()',
'zod.union([zod.string(), zod.number()]).optional()',
);
});
});
Expand Down