Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"react-virtualized-auto-sizer": "^1.0.20",
"react-window": "^1.8.10",
"stainless": "workspace:*",
"zod": "^3.25.76",
"zod": "^4.3.6",
"zustand": "^4.4.7"
},
"devDependencies": {
Expand Down
3 changes: 2 additions & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"prettier": "^2.8.8",
"stainless": "workspace:*",
"typescript": "^5.3.2",
"zod-to-ts": "^1.2.0"
"zod": "^4.3.6",
"zod-to-ts": "^2.0.0"
},
"devDependencies": {
"@tanstack/react-query": "^5.28.0",
Expand Down
190 changes: 188 additions & 2 deletions packages/client/src/codegen/generate-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { printNode, zodToTs } from "zod-to-ts";
import { printNode, zodToTs, createAuxiliaryTypeStore } from "zod-to-ts";
import { APIConfig, ClientConfig } from "../core/api-client-types";
import { AnyActionsConfig, ResourceConfig, z } from "stainless";
import { splitPathIntoParts } from "../core/endpoint";
Expand Down Expand Up @@ -145,8 +145,194 @@ function nestEndpoints(
return api;
}

// Map zod/v3 typeName (e.g. "ZodString") to zod v4 type (e.g. "string")
function mapZodV3TypeToV4(typeName: string): string {
// Remove "Zod" prefix and lowercase
if (typeName.startsWith("Zod")) {
const type = typeName.slice(3).toLowerCase();
// Map native enums to enum since zod-to-ts v2 only handles "enum"
if (type === "nativeenum") {
return "enum";
}
return type;
}
return typeName.toLowerCase();
}

// Wrap a value to recursively add _zod to any nested schemas
function wrapSchemaValue(value: any, cache: WeakMap<object, any>): any {
if (!value || typeof value !== "object") return value;

// Check if it's a zod schema (has _def)
if (value._def && !value._zod) {
return wrapSchemaForZodToTs(value, cache);
}

// Wrap arrays (for union options, tuple items, etc.)
if (Array.isArray(value)) {
return value.map((item) => wrapSchemaValue(item, cache));
}

// Wrap plain objects (for object shapes)
if (value.constructor === Object) {
const result: Record<string, any> = {};
for (const key of Object.keys(value)) {
result[key] = wrapSchemaValue(value[key], cache);
}
return result;
}

return value;
}

// Create a v4-compatible _zod.def object from v3 _def
function createV4CompatDef(v3Def: any, cache: WeakMap<object, any>): any {
const type = mapZodV3TypeToV4(v3Def.typeName);

const result: any = {
type,
};

// Map v3 property names to v4 equivalents and wrap nested schemas
// Array: v3 uses _def.type, v4 uses def.element
if (v3Def.type && v3Def.type._def) {
result.element = wrapSchemaValue(v3Def.type, cache);
}

// Object shape: v3 uses _def.shape() function, v4 uses def.shape object
if (typeof v3Def.shape === "function") {
const shapeObj = v3Def.shape();
result.shape = wrapSchemaValue(shapeObj, cache);
} else if (v3Def.shape && typeof v3Def.shape === "object") {
result.shape = wrapSchemaValue(v3Def.shape, cache);
}

// Optional/Nullable: innerType
if (v3Def.innerType) {
result.innerType = wrapSchemaValue(v3Def.innerType, cache);
}

// Union: options array
if (v3Def.options) {
result.options = wrapSchemaValue(v3Def.options, cache);
}

// Intersection: left and right
if (v3Def.left) {
result.left = wrapSchemaValue(v3Def.left, cache);
}
if (v3Def.right) {
result.right = wrapSchemaValue(v3Def.right, cache);
}

// Tuple: items
if (v3Def.items) {
result.items = wrapSchemaValue(v3Def.items, cache);
}

// Record/Map: keyType and valueType
if (v3Def.keyType) {
result.keyType = wrapSchemaValue(v3Def.keyType, cache);
}
if (v3Def.valueType) {
result.valueType = wrapSchemaValue(v3Def.valueType, cache);
}

// Lazy: getter
if (v3Def.getter) {
result.getter = () => wrapSchemaValue(v3Def.getter(), cache);
}

// Effects (transform/refine): schema/innerType
if (v3Def.schema) {
result.innerType = wrapSchemaValue(v3Def.schema, cache);
}

// Enum: entries or values
// v3 ZodEnum has values as array, v3 ZodNativeEnum has values as object
if (v3Def.values) {
if (Array.isArray(v3Def.values)) {
result.entries = v3Def.values.reduce(
(acc: Record<string, string>, v: string) => {
acc[v] = v;
return acc;
},
{}
);
} else if (typeof v3Def.values === "object") {
// Native enum - values is already an object
result.entries = v3Def.values;
}
}

// Literal: values array
if (v3Def.value !== undefined) {
result.values = [v3Def.value];
}

// Promise: innerType
if (v3Def.type && type === "promise") {
result.innerType = wrapSchemaValue(v3Def.type, cache);
}

// Catchall for objects - skip if it's ZodNever (strict mode default)
// because `[x: string]: never` is semantically "no extra properties" but
// generates invalid TypeScript when combined with known properties
if (v3Def.catchall && v3Def.catchall._def?.typeName !== "ZodNever") {
result.catchall = wrapSchemaValue(v3Def.catchall, cache);
}

return result;
}

// Recursively add _zod property to a schema for zod-to-ts v2 compatibility
function wrapSchemaForZodToTs(
schema: any,
cache: WeakMap<object, any> = new WeakMap()
): any {
if (!schema || typeof schema !== "object") return schema;

// Skip if already has _zod (native zod v4)
if (schema._zod) return schema;

// Skip if no _def (not a zod schema)
if (!schema._def) return schema;

// Check cache to handle circular references
if (cache.has(schema)) {
return cache.get(schema);
}

// Create wrapped schema with _zod property
const wrapped = Object.create(Object.getPrototypeOf(schema));
cache.set(schema, wrapped);

// Copy all own properties from original
Object.assign(wrapped, schema);

// Add _zod property
Object.defineProperty(wrapped, "_zod", {
get() {
return {
def: createV4CompatDef(schema._def, cache),
optin: false,
optout: schema.isOptional?.() ?? false,
};
},
configurable: true,
enumerable: false,
});

return wrapped;
}

function zodToString(schema: ZodTypeAny) {
const { node } = zodToTs(schema, undefined, { nativeEnums: "union" });
// Wrap the schema to add _zod property for zod-to-ts v2 compatibility
const wrappedSchema = wrapSchemaForZodToTs(schema);

// zod-to-ts v2 API: zodToTs(schema, options)
const auxiliaryTypeStore = createAuxiliaryTypeStore();
const { node } = zodToTs(wrappedSchema as any, { auxiliaryTypeStore });
const nodeString = printNode(node);
// This happens with large, lazily loaded zod types
return nodeString.replace(/\bIdentifier\b/g, "unknown");
Expand Down
4 changes: 4 additions & 0 deletions packages/client/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,9 @@ export default defineConfig({
test: {
typecheck: { enabled: true },
setupFiles: ["/src/test-util/setup.ts"],
deps: {
// Inline zod-to-ts to handle ESM/CJS interop with typescript package
inline: ["zod-to-ts"],
},
},
});
2 changes: 1 addition & 1 deletion packages/prisma/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"dependencies": {
"lodash": "^4.17.21",
"stainless": "workspace:*",
"zod": "^3.25.76"
"zod": "^4.3.6"
},
"peerDependencies": {
"prisma": "^4.0.0"
Expand Down
8 changes: 5 additions & 3 deletions packages/prisma/src/prismaPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import {
import { includeSubPaths } from "./includeUtils";
import { isPlainObject } from "lodash";

declare module "zod" {
interface ZodType<Output, Def extends ZodTypeDef, Input = Output> {
// Module augmentation for zod/v3 (stainless re-exports zod/v3 types)
declare module "zod/v3" {
interface ZodType<Output, Def extends z.ZodTypeDef, Input = Output> {
/**
* Transforms the output value to fetch the Prisma model whose primary
* key is the input value. Throws if the primary key wasn't found.
Expand Down Expand Up @@ -79,7 +80,8 @@ z.ZodType.prototype.prismaModelLoader = function prismaModelLoader<
}
);
// tsc -b is generating spurious errors here...
return (result as any).openapi({ effectType: "input" }) as typeof result;
// Removed .openapi() call - zod-openapi v5 uses .meta() which isn't available in zod/v3
return result;
};

z.ZodType.prototype.prismaModel = function prismaModel<
Expand Down
4 changes: 2 additions & 2 deletions packages/stainless/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@
"lodash": "^4.17.21",
"qs": "^6.11.2",
"ts-node": "^10.9.1",
"zod": "^3.25.76",
"zod-openapi": "github:stainless-api/zod-openapi#2.8.0",
"zod": "^4.3.6",
"zod-openapi": "^5.4.6",
"zod-validation-error": "^4.0.1"
}
}
7 changes: 5 additions & 2 deletions packages/stainless/src/openapiSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import {
ZodOpenApiOperationObject,
ZodOpenApiPathsObject,
createDocument,
oas31,
} from "zod-openapi";
import type { OpenAPIObject } from "zod-openapi/lib-types/openapi3-ts/dist/oas31";
type OpenAPIObject = oas31.OpenAPIObject;
import { snakeCase } from "lodash";

function allModels(
Expand Down Expand Up @@ -87,7 +88,9 @@ export async function openapiSpec(
},
servers: [{ url: "v1" }],
components: {
schemas: models,
// Cast to any because zod/v3 types are structurally compatible with zod/v4
// at runtime, but TypeScript sees them as incompatible types
schemas: models as any,
},
paths,
});
Expand Down
3 changes: 2 additions & 1 deletion packages/stainless/src/stl.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as z from "./z";
import { openapiSpec } from "./openapiSpec";
import type { OpenAPIObject } from "zod-openapi/lib-types/openapi3-ts/dist/oas31";
import type { oas31 } from "zod-openapi";
type OpenAPIObject = oas31.OpenAPIObject;
import { fromZodError } from "zod-validation-error/v3";
import coerceParams from "./coerceParams";
export { openapiSpec };
Expand Down
29 changes: 12 additions & 17 deletions packages/stainless/src/z.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { extendZodWithOpenApi } from "zod-openapi";
// Use zod/v3 compatibility layer for easier migration
// zod-openapi v5 uses .meta() instead of .openapi(), no setup required
import "zod-openapi";
import {
z,
ParseContext,
SafeParseReturnType,
isValid,
ZodFirstPartyTypeKind,
} from "zod";
} from "zod/v3";
import { StlContext } from "./stl";
import { SelectTree } from "./parseSelect";
import { getSelects } from "./selects";
import { getIncludes, IncludablePaths } from "./includes";
import { pickBy } from "lodash/fp";
import { mapValues } from "lodash";

export * from "zod";
export * from "zod/v3";
export { selects, selectsSymbol, getSelects } from "./selects";
export {
includes,
Expand All @@ -22,21 +24,16 @@ export {
IncludablePaths,
} from "./includes";

/**
* TODO: try to come up with a better error message
* that you must import stl _before_ zod
* in any file that uses z.openapi(),
* including the file that calls stl.openapiSpec().
*/
extendZodWithOpenApi(z); // https://github.com/asteasolutions/zod-to-openapi#the-openapi-method
// zod-openapi v5 uses Zod's native .meta() method for OpenAPI metadata
// No setup required - importing "zod-openapi" above enables TypeScript types

//////////////////////////////////////////////////
//////////////////////////////////////////////////
////////////////// Metadata //////////////////////
//////////////////////////////////////////////////
//////////////////////////////////////////////////

declare module "zod" {
declare module "zod/v3" {
interface ZodType<Output, Def extends ZodTypeDef, Input = Output> {
withMetadata<M extends object>(metadata: M): ZodMetadata<this, M>;
}
Expand Down Expand Up @@ -228,7 +225,7 @@ export function extractDeepMetadata<
//////////////////////////////////////////////////
//////////////////////////////////////////////////

declare module "zod" {
declare module "zod/v3" {
interface ZodType<Output, Def extends ZodTypeDef, Input = Output> {
/**
* Marks this schema as includable via an `include[]` query param.
Expand Down Expand Up @@ -266,9 +263,7 @@ z.ZodType.prototype.includable = function includable(this: z.ZodTypeAny) {
return include && zodPathIsIncluded(path, include) ? data : undefined;
},
this.optional()
)
.openapi({ effectType: "input" })
.withMetadata({ stainless: { includable: true } });
).withMetadata({ stainless: { includable: true } });
};

export type isIncludable<T extends z.ZodTypeAny> = extractDeepMetadata<
Expand Down Expand Up @@ -301,7 +296,7 @@ function zodPathIsIncluded(
//////////////////////////////////////////////////
//////////////////////////////////////////////////

declare module "zod" {
declare module "zod/v3" {
// I don't know why TS errors without this, sigh
interface ZodTypeDef {}

Expand Down Expand Up @@ -438,7 +433,7 @@ z.ZodType.prototype.selectable = function selectable(this: z.ZodTypeAny) {
//////////////////////////////////////////////////
//////////////////////////////////////////////////

declare module "zod" {
declare module "zod/v3" {
interface ZodType<Output, Def extends ZodTypeDef, Input = Output> {
safeParseAsync(
data: unknown,
Expand Down
2 changes: 1 addition & 1 deletion packages/ts-to-zod/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,6 @@
"lodash": "^4.17.21",
"pkg-up": "~3.1.0",
"ts-morph": "^19.0.0",
"zod": "^3.25.76"
"zod": "^4.3.6"
}
}
Loading