Drop Zod v3 support from new configSchema path

The new `configSchema` option now strictly requires StandardSchemaV1
values (e.g. Zod v4 or `zod/v4` from the Zod v3 package). Direct Zod
v3 schemas are no longer silently converted and will throw an error.

The deprecated `config.schema` callback path continues to work with
Zod v3 through a separate `createDeprecatedConfigSchema` function.

Also adds `createZodV4FilterPredicateSchema` to `@backstage/filter-predicates`
as a v4 counterpart to the now-deprecated v3 variant.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-04-13 17:15:46 +02:00
parent 25392cab00
commit 8923d6def0
18 changed files with 256 additions and 173 deletions
@@ -14,77 +14,15 @@
* limitations under the License.
*/
import { z as zodV3 } from 'zod/v3';
import { z as zodV4 } from 'zod/v4';
import {
createConfigSchema,
createDeprecatedConfigSchema,
mergePortableSchemas,
} from './createPortableSchema';
describe('createConfigSchema', () => {
describe('zod v3 schemas', () => {
it('should report a missing required field', () => {
const schema = createConfigSchema({ name: z => z.string() });
expect(() => schema.parse({})).toThrow(
"Missing required value at 'name'",
);
expect(() => schema.parse(undefined)).toThrow(
"Missing required value at 'name'",
);
});
it('should report a type mismatch', () => {
const schema = createConfigSchema({ count: z => z.number() });
expect(() => schema.parse({ count: 'not a number' })).toThrow(
"Expected number, received string at 'count'",
);
});
it('should report nested object errors with the full path', () => {
const schema = createConfigSchema({
settings: z => z.object({ port: z.number() }),
});
expect(() => schema.parse({ settings: { port: 'abc' } })).toThrow(
"Expected number, received string at 'settings.port'",
);
});
it('should report errors for union types', () => {
const schema = createConfigSchema({
value: z => z.union([z.string(), z.number()]),
});
expect(() => schema.parse({})).toThrow(
"Missing required value at 'value'",
);
});
it('should combine errors from multiple fields', () => {
const schema = createConfigSchema({
name: z => z.string(),
count: z => z.number(),
});
expect(() => schema.parse({})).toThrow(
"Missing required value at 'name'; Missing required value at 'count'",
);
});
it('should apply defaults for optional fields with defaults', () => {
const schema = createConfigSchema({
name: z => z.string(),
mode: z => z.enum(['fast', 'slow']).default('fast'),
});
expect(schema.parse({ name: 'test' })).toEqual({
name: 'test',
mode: 'fast',
});
});
});
describe('zod v4 schemas', () => {
it('should report a missing required field', () => {
const schema = createConfigSchema({ name: zodV4.string() });
@@ -150,57 +88,22 @@ describe('createConfigSchema', () => {
});
});
describe('mixed zod v3 and v4 schemas', () => {
it('should validate fields from both schema versions', () => {
const schema = createConfigSchema({
v3field: z => z.string(),
v4field: zodV4.number(),
});
expect(schema.parse({ v3field: 'hello', v4field: 42 })).toEqual({
v3field: 'hello',
v4field: 42,
});
});
it('should report errors from both schema versions', () => {
const schema = createConfigSchema({
v3field: z => z.string(),
v4field: zodV4.number(),
});
expect(() => schema.parse({})).toThrow(
"Missing required value at 'v3field'; " +
"Invalid input: expected number, received undefined at 'v4field'",
describe('zod v3 rejection', () => {
it('should reject a direct zod v3 schema', () => {
expect(() => createConfigSchema({ name: zodV3.string() as any })).toThrow(
"Config schema for field 'name' uses a Zod v3 schema, which is " +
'not supported by the `configSchema` option. Either use ' +
"`import { z } from 'zod/v4'` from the zod v3 package, or " +
'upgrade to zod v4.',
);
});
it('should produce correct JSON Schema for mixed schemas', () => {
const schema = createConfigSchema({
v3field: z => z.string(),
v4field: zodV4.number().optional(),
});
const result = schema.schema();
expect(result.schema).toMatchObject({
type: 'object',
properties: {
v3field: { type: 'string' },
v4field: { type: 'number' },
},
required: ['v3field'],
additionalProperties: false,
});
});
});
describe('schema creation errors', () => {
it('should reject a schema that is not a valid Standard Schema or zod schema', () => {
it('should reject a schema that is not a valid Standard Schema', () => {
expect(() =>
createConfigSchema({ bad: { notASchema: true } as any }),
).toThrow(
"Config schema for field 'bad' is not a valid Standard Schema or zod schema",
);
).toThrow("Config schema for field 'bad' is not a valid Standard Schema");
});
it('should reject a Standard Schema without JSON Schema support', () => {
@@ -223,8 +126,8 @@ describe('createConfigSchema', () => {
describe('JSON Schema generation', () => {
it('should generate JSON Schema lazily via schema()', () => {
const schema = createConfigSchema({
title: z => z.string(),
count: z => z.number().optional(),
title: zodV4.string(),
count: zodV4.number().optional(),
});
const result = schema.schema();
@@ -242,7 +145,7 @@ describe('createConfigSchema', () => {
it('should support backward-compatible property access on schema', () => {
const schema = createConfigSchema({
title: z => z.string(),
title: zodV4.string(),
});
expect(schema.schema.type).toBe('object');
@@ -251,9 +154,9 @@ describe('createConfigSchema', () => {
});
describe('merging schemas', () => {
it('should merge two zod v3 schemas and parse correctly', () => {
const a = createConfigSchema({ name: z => z.string() });
const b = createConfigSchema({ count: z => z.number().default(0) });
it('should merge two zod v4 schemas and parse correctly', () => {
const a = createConfigSchema({ name: zodV4.string() });
const b = createConfigSchema({ count: zodV4.number().default(0) });
const merged = mergePortableSchemas(a, b)!;
expect(merged.parse({ name: 'hello' })).toEqual({
@@ -262,8 +165,8 @@ describe('createConfigSchema', () => {
});
});
it('should merge zod v3 and v4 schemas', () => {
const a = createConfigSchema({ name: z => z.string() });
it('should merge deprecated v3 and new v4 schemas', () => {
const a = createDeprecatedConfigSchema({ name: z => z.string() });
const b = createConfigSchema({ count: zodV4.number().default(0) });
const merged = mergePortableSchemas(a, b)!;
@@ -274,7 +177,7 @@ describe('createConfigSchema', () => {
});
it('should produce combined errors after merge', () => {
const a = createConfigSchema({ name: z => z.string() });
const a = createDeprecatedConfigSchema({ name: z => z.string() });
const b = createConfigSchema({ count: zodV4.number() });
const merged = mergePortableSchemas(a, b)!;
@@ -286,7 +189,7 @@ describe('createConfigSchema', () => {
});
it('should produce combined errors for type mismatches after merge', () => {
const a = createConfigSchema({ name: z => z.string() });
const a = createDeprecatedConfigSchema({ name: z => z.string() });
const b = createConfigSchema({ count: zodV4.number() });
const merged = mergePortableSchemas(a, b)!;
@@ -298,7 +201,7 @@ describe('createConfigSchema', () => {
});
it('should produce correct JSON Schema after merge', () => {
const a = createConfigSchema({ name: z => z.string() });
const a = createDeprecatedConfigSchema({ name: z => z.string() });
const b = createConfigSchema({ count: zodV4.number().optional() });
const merged = mergePortableSchemas(a, b)!;
@@ -316,7 +219,7 @@ describe('createConfigSchema', () => {
});
it('should handle merge with undefined', () => {
const a = createConfigSchema({ name: z => z.string() });
const a = createConfigSchema({ name: zodV4.string() });
expect(mergePortableSchemas(a, undefined)).toBe(a);
expect(mergePortableSchemas(undefined, a)).toBe(a);
@@ -324,7 +227,7 @@ describe('createConfigSchema', () => {
});
it('should let later fields win when merging overlapping keys', () => {
const a = createConfigSchema({ x: z => z.string() });
const a = createDeprecatedConfigSchema({ x: z => z.string() });
const b = createConfigSchema({ x: zodV4.number() });
const merged = mergePortableSchemas(a, b)!;
@@ -351,7 +254,7 @@ describe('createConfigSchema', () => {
});
it('should throw a clear error for non-object input', () => {
const schema = createConfigSchema({ title: z => z.string() });
const schema = createConfigSchema({ title: zodV4.string() });
expect(() => schema.parse('not an object')).toThrow(
'Invalid config input, expected object but got string',
@@ -369,9 +272,9 @@ describe('createConfigSchema', () => {
it('should not produce undefined keys for absent optional fields', () => {
const schema = createConfigSchema({
name: z => z.string(),
title: z => z.string().optional(),
count: z => z.number().default(42),
name: zodV4.string(),
title: zodV4.string().optional(),
count: zodV4.number().default(42),
});
const result = schema.parse({ name: 'hello' });
@@ -379,11 +282,11 @@ describe('createConfigSchema', () => {
expect(Object.keys(result as object)).toEqual(['name', 'count']);
});
it('should not mark defaulted zod v3 fields as required in JSON Schema', () => {
it('should not mark defaulted fields as required in JSON Schema', () => {
const schema = createConfigSchema({
name: z => z.string(),
title: z => z.string().default('hello'),
count: z => z.number().optional(),
name: zodV4.string(),
title: zodV4.string().default('hello'),
count: zodV4.number().optional(),
});
const result = schema.schema();
@@ -394,3 +297,85 @@ describe('createConfigSchema', () => {
});
});
});
describe('createDeprecatedConfigSchema', () => {
it('should report a missing required field', () => {
const schema = createDeprecatedConfigSchema({ name: z => z.string() });
expect(() => schema.parse({})).toThrow("Missing required value at 'name'");
expect(() => schema.parse(undefined)).toThrow(
"Missing required value at 'name'",
);
});
it('should report a type mismatch', () => {
const schema = createDeprecatedConfigSchema({ count: z => z.number() });
expect(() => schema.parse({ count: 'not a number' })).toThrow(
"Expected number, received string at 'count'",
);
});
it('should report nested object errors with the full path', () => {
const schema = createDeprecatedConfigSchema({
settings: z => z.object({ port: z.number() }),
});
expect(() => schema.parse({ settings: { port: 'abc' } })).toThrow(
"Expected number, received string at 'settings.port'",
);
});
it('should report errors for union types', () => {
const schema = createDeprecatedConfigSchema({
value: z => z.union([z.string(), z.number()]),
});
expect(() => schema.parse({})).toThrow("Missing required value at 'value'");
});
it('should apply defaults for optional fields with defaults', () => {
const schema = createDeprecatedConfigSchema({
name: z => z.string(),
mode: z => z.enum(['fast', 'slow']).default('fast'),
});
expect(schema.parse({ name: 'test' })).toEqual({
name: 'test',
mode: 'fast',
});
});
it('should generate JSON Schema lazily via schema()', () => {
const schema = createDeprecatedConfigSchema({
title: z => z.string(),
count: z => z.number().optional(),
});
const result = schema.schema();
expect(result).toHaveProperty('schema');
expect(result.schema).toMatchObject({
type: 'object',
properties: {
title: { type: 'string' },
count: { type: 'number' },
},
required: ['title'],
additionalProperties: false,
});
});
it('should not mark defaulted fields as required in JSON Schema', () => {
const schema = createDeprecatedConfigSchema({
name: z => z.string(),
title: z => z.string().default('hello'),
count: z => z.number().optional(),
});
const result = schema.schema();
expect(result.schema).toMatchObject({
type: 'object',
required: ['name'],
});
});
});
@@ -26,6 +26,19 @@ import { PortableSchema } from './types';
export type { StandardSchemaV1 } from '@standard-schema/spec';
import { type StandardSchemaV1 } from '@standard-schema/spec';
/** @internal */
export function createDeprecatedConfigSchema(
fields: Record<string, (zImpl: typeof zodV3) => ZodType>,
): MergeablePortableSchema {
const resolved: Record<string, ResolvedField> = {};
for (const [key, field] of Object.entries(fields)) {
resolved[key] = resolveZodField(key, field(zodV3));
}
return buildPortableSchema(resolved);
}
/**
* Per-field resolved schema — validation is eager, JSON Schema is lazy.
* @internal
@@ -53,13 +66,12 @@ export interface MergeablePortableSchema<TOutput = any, TInput = any>
* @internal
*/
export function createConfigSchema(
fields: Record<string, StandardSchemaV1 | ((zImpl: typeof zodV3) => ZodType)>,
fields: Record<string, StandardSchemaV1>,
): MergeablePortableSchema {
const resolved: Record<string, ResolvedField> = {};
for (const [key, field] of Object.entries(fields)) {
const schema = typeof field === 'function' ? field(zodV3) : field;
resolved[key] = resolveField(key, schema);
resolved[key] = resolveField(key, field);
}
return buildPortableSchema(resolved);
@@ -165,20 +177,25 @@ function buildPortableSchema<TOutput = unknown>(
*/
function resolveField(key: string, schema: unknown): ResolvedField {
if (isZodV3Type(schema)) {
return resolveZodField(key, schema);
throw new Error(
`Config schema for field '${key}' uses a Zod v3 schema, which is ` +
`not supported by the \`configSchema\` option. Either use ` +
`\`import { z } from 'zod/v4'\` from the zod v3 package, or ` +
`upgrade to zod v4.`,
);
}
if (isStandardSchema(schema)) {
if (!hasJsonSchemaConverter(schema)) {
throw new Error(
`Config schema for field '${key}' does not support JSON Schema ` +
`conversion. Use a schema library that implements the Standard ` +
`JSON Schema interface (like zod v4+), or use a zod v3 schema.`,
`JSON Schema interface (like zod v4+).`,
);
}
return resolveStandardField(key, schema);
}
throw new Error(
`Config schema for field '${key}' is not a valid Standard Schema or zod schema`,
`Config schema for field '${key}' is not a valid Standard Schema`,
);
}
@@ -340,7 +357,8 @@ export function warnConfigSchemaPropDeprecation(callSite: string) {
console.warn(
`DEPRECATION WARNING: The \`config.schema\` option for extension config is deprecated. ` +
`Use the \`configSchema\` option instead with Standard Schema values, for example ` +
`\`configSchema: { title: z.string() }\` using zod v3.25+ or v4. ` +
`\`configSchema: { title: z.string() }\` using zod v4 ` +
`(or \`import { z } from 'zod/v4'\` from the zod v3 package). ` +
`Declared at ${callSite}`,
);
}
@@ -29,6 +29,8 @@ import { ExtensionInput } from './createExtensionInput';
import type { z } from 'zod/v3';
import {
createConfigSchema,
createDeprecatedConfigSchema,
mergePortableSchemas,
warnConfigSchemaPropDeprecation,
} from '../schema/createPortableSchema';
import { describeParentCallSite } from '../routing/describeParentCallSite';
@@ -618,13 +620,15 @@ export function createExtension<
export function createExtension(
options: any,
): OverridableExtensionDefinition<any> {
const schemaDeclaration =
options.configSchema ?? (options.config?.schema as any);
if (options.config?.schema) {
warnConfigSchemaPropDeprecation(describeParentCallSite());
}
const resolvedConfigSchema =
schemaDeclaration && createConfigSchema(schemaDeclaration);
const resolvedConfigSchema = mergePortableSchemas(
options.config?.schema
? createDeprecatedConfigSchema(options.config.schema)
: undefined,
options.configSchema ? createConfigSchema(options.configSchema) : undefined,
);
return OpaqueExtensionDefinition.createInstance('v2', {
T: undefined as any,
@@ -730,15 +734,19 @@ export function createExtension(
),
output: (overrideOptions.output ??
options.output) as ExtensionDataRef[],
configSchema:
options.config ||
options.configSchema ||
overrideOptions.config ||
overrideOptions.configSchema
config:
options.config?.schema || overrideOptions.config?.schema
? {
schema: {
...options.config?.schema,
...overrideOptions.config?.schema,
},
}
: undefined,
configSchema:
options.configSchema || overrideOptions.configSchema
? {
...options.config?.schema,
...options.configSchema,
...overrideOptions.config?.schema,
...overrideOptions.configSchema,
}
: undefined,
@@ -31,7 +31,7 @@ import {
import { createExtensionInput } from './createExtensionInput';
import { RouteRef } from '../routing';
import { createExtension, ExtensionDefinition } from './createExtension';
import { z as zodV3 } from 'zod/v3';
import { z as zodV4 } from 'zod/v4';
import {
createExtensionDataContainer,
OpaqueExtensionDefinition,
@@ -316,7 +316,7 @@ describe('createExtensionBlueprint', () => {
attachTo: { id: 'test', input: 'default' },
output: [coreExtensionData.reactElement],
configSchema: {
title: zodV3.string().default('default title'),
title: zodV4.string().default('default title'),
},
factory(_, { config }) {
return [
@@ -743,15 +743,19 @@ export function createExtensionBlueprint(options: any): any {
if: args.if ?? options.if,
inputs: { ...args.inputs, ...options.inputs },
output: (args.output ?? options.output) as ExtensionDataRef[],
configSchema:
options.configSchema ||
args.configSchema ||
options.config?.schema ||
args.config?.schema
config:
options.config?.schema || args.config?.schema
? {
schema: {
...options.config?.schema,
...args.config?.schema,
},
}
: undefined,
configSchema:
options.configSchema || args.configSchema
? {
...options.config?.schema,
...options.configSchema,
...args.config?.schema,
...args.configSchema,
}
: (undefined as any),