Skip to content
Closed
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
33 changes: 22 additions & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@
"typescript": "^5.9.2",
"vitest": "^3.0.9",
"wasm-pack": "^0.0.0",
"wasm-pack-inline": "^0.1.2"
"wasm-pack-inline": "^0.1.2",
"zod": "^4.1.4"
},
"engines": {
"node": ">= 20.19"
Expand Down
5 changes: 3 additions & 2 deletions packages/restate-sdk-examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@
"@restatedev/restate-sdk-clients": "^1.8.3"
},
"devDependencies": {
"tsx": "^4.15.7",
"@restatedev/restate-sdk-testcontainers": "^1.8.3"
"@restatedev/restate-sdk-core": "^1.8.3",
"@restatedev/restate-sdk-testcontainers": "^1.8.3",
"tsx": "^4.15.7"
},
"engines": {
"node": ">= 22.15"
Expand Down
9 changes: 9 additions & 0 deletions packages/restate-sdk-zod/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ To use this library, add the dependency to your project:

```shell
npm install --save-dev @restatedev/restate-sdk-zod
npm install zod # optional if zod isn't already installed
```

# Zod v3 Users
As a minimum requirement, `zod` must be at `v3.25.0`. This is a non-breaking release for existing `zod v3` users.
Also, `zod-to-json-schema` is required. Add the following additional dependencies to your project:

```shell
npm install zod-to-json-schema zod@^3.25.0
```

## Versions
Expand Down
11 changes: 7 additions & 4 deletions packages/restate-sdk-zod/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@
"dist"
],
"devDependencies": {
"@restatedev/restate-sdk-core": "^1.8.3"
"@restatedev/restate-sdk-core": "^1.8.3",
"vitest": "^3.0.9",
"zod": "^3.25.0 || ^4.0.0",
"zod-to-json-schema": "^3.24.6"
},
"dependencies": {
"zod": "^3.24.1",
"zod-to-json-schema": "3.24.3"
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0",
"zod-to-json-schema": "^3.24.6"
},
"scripts": {
"api:extract": "api-extractor run --local",
Expand Down
59 changes: 43 additions & 16 deletions packages/restate-sdk-zod/src/serde_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,51 +17,78 @@

import type { Serde } from "@restatedev/restate-sdk-core";

import { z, ZodVoid } from "zod";
import * as z3 from "zod/v3";
import * as z4 from "zod/v4/core";
import { zodToJsonSchema } from "zod-to-json-schema";

export type { Serde } from "@restatedev/restate-sdk-core";

class ZodSerde<T extends z.ZodType> implements Serde<z.infer<T>> {
export type V3ZodObjectOrWrapped =
| z3.ZodObject<any, any>
| z3.ZodEffects<z3.ZodObject<any, any>>;

type ZodType = z3.ZodTypeAny | z4.$ZodType
type ZodObject = V3ZodObjectOrWrapped | z4.$ZodObject


type output<T> = T extends z3.ZodTypeAny | V3ZodObjectOrWrapped ? z3.infer<T> : z4.infer<T>;

class ZodSerde<T extends ZodType | ZodObject> implements Serde<output<T>> {
contentType? = "application/json";
jsonSchema?: object | undefined;

constructor(private readonly schema: T) {
this.jsonSchema = zodToJsonSchema(schema);
if (schema instanceof ZodVoid || schema instanceof z.ZodUndefined) {
this.contentType = undefined;
}
if ("_zod" in schema) {
this.jsonSchema = z4.toJSONSchema(schema);
} else if (schema instanceof z3.ZodType) {
// zod3 fallback
this.jsonSchema = zodToJsonSchema(schema as never);
} else {
this.jsonSchema = undefined;
}

if (schema instanceof z3.ZodVoid
|| schema instanceof z3.ZodUndefined
|| schema instanceof z4.$ZodVoid
|| schema instanceof z4.$ZodUndefined) {
this.contentType = undefined;
}
}

serialize(value: T): Uint8Array {


serialize(value: output<T>): Uint8Array {
if (value === undefined) {
return new Uint8Array(0);
}
return new TextEncoder().encode(JSON.stringify(value));
}

deserialize(data: Uint8Array): T {
deserialize(data: Uint8Array): output<T> {
const js =
data.length === 0
? undefined
: JSON.parse(new TextDecoder().decode(data));

const res = this.schema.safeParse(js);
if (res.success) {
return res.data;
if ('safeParse' in this.schema && typeof this.schema.safeParse === 'function') {
const res = this.schema.safeParse(js);
if (res.success) {
return res.data;
}
throw res.error;
} else {
throw new TypeError("Unsupported data type. Expected 'safeParse'.");
}
throw res.error;
}
}

export namespace serde {
/**
* A Zod based serde.
* A Zod-based serde.
*
* @param schema the zod type
* @param zodType the zod type
* @returns a serde that will validate the data with the zod schema
*/
export const zod = <T extends z.ZodType>(zodType: T): Serde<z.infer<T>> => {
export const zod = <T extends ZodType | ZodObject>(zodType: T): Serde<output<T>> => {
return new ZodSerde(zodType);
};
}
106 changes: 106 additions & 0 deletions packages/restate-sdk-zod/test/serde_api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {describe, expect, it} from "vitest";
import * as z3 from "zod/v3";
import * as z4 from "zod/v4";
import { serde } from "../src/serde_api.js";
import { zodToJsonSchema } from "zod-to-json-schema";


/* eslint-disable @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access */

const typeTestData = {
name: "Type Test",
validData: {
id: 1,
name: "test",
isActive: true,
},
invalidData: {
id: -1, // not positive
name: "a", // less than 3 characters
isActive: "not a boolean",
},
};

const z3TypeTestData = {
...typeTestData,
name: `v3 ${typeTestData.name}`,
zodSchema: z3.object({
id: z3.number().int().positive(),
name: z3.string().min(3, { message: "Name must be at least 3 characters long" }),
isActive: z3.boolean(),
}),
jsonSchema: undefined,
};
z3TypeTestData.jsonSchema = zodToJsonSchema(z3TypeTestData.zodSchema as never)

const z4TypeTestData = {
...typeTestData,
name: `v4 ${typeTestData.name}`,
zodSchema: z4.object({
id: z4.number().int().positive(),
name: z4.string().min(3, { message: "Name must be at least 3 characters long" }),
isActive: z4.boolean(),
}),
jsonSchema: undefined,
};
z4TypeTestData.jsonSchema = z4.toJSONSchema(z4TypeTestData.zodSchema);


const stringTestData = {
name: "stringTest",
validData: "just a string",
invalidData: -1,
};

const z3StringTestData = {
...stringTestData,
name: `v3 ${stringTestData.name}`,
zodSchema: z3.string(),
jsonSchema: undefined,
};
z3StringTestData.jsonSchema = zodToJsonSchema(z3StringTestData.zodSchema as never);

const z4StringTestData = {
...stringTestData,
name: `v4 ${stringTestData.name}`,
zodSchema: z4.string(),
jsonSchema: undefined,
};
z4StringTestData.jsonSchema = z4.toJSONSchema(z4StringTestData.zodSchema);


describe('serde_api', () => {
describe.each([
z3TypeTestData,
z4TypeTestData,
z3StringTestData,
z4StringTestData,
])("zod $name", ({ zodSchema, jsonSchema, validData, invalidData }) => {
const validDataSerialized = new TextEncoder().encode(JSON.stringify(validData));
const invalidDataSerialized = new TextEncoder().encode(JSON.stringify(invalidData))
const srd = serde.zod(zodSchema);

describe('constructor', () => {
it('converts a valid schema to json', () => {
expect(srd.jsonSchema).not.to.be.null;
expect(srd.jsonSchema).to.deep.equal(jsonSchema);
});
})
describe('serialize', () => {
it('serializes a valid object', () => {
expect(srd.serialize(validData)).to.deep.equal(validDataSerialized);
});
it('gives an empty response for undefined', () => {
expect(srd.serialize(undefined)).to.be.empty;
})
})
describe('deserialize', () => {
it('deserializes a valid object', () => {
expect(srd.deserialize(new TextEncoder().encode(JSON.stringify(validData)))).to.deep.equal(validData);
});
it('throws an error on an invalid object', () => {
expect(() => srd.deserialize(invalidDataSerialized)).throws();
});
});
});
});
Loading