@czfabrics/zod-to-proto
v0.2.0
Published
A TypeScript library for seamlessly converting Zod schemas into Protocol Buffers v3 definitions, with built-in support for generating RPC services and functions from structured object definitions.
Maintainers
Readme
Table of Contents
Overview
A TypeScript library for seamlessly converting Zod schemas into Protocol Buffers v3 definitions, with built-in support for generating RPC services and functions from structured object definitions.
Key Features
- Zod support: Write your Zod schemas and convert them to Protobuf definitions.
- Typesafe Zod conversion: If it throws a TypeScript error, it’s not supported. Simple as that.
- gRPC support: Define your gRPC services in a TypeScript-first way.
- Type-safe structured objects: Write your Protobuf definitions in a TypeScript-first way and convert them into proto3 files.
- Flexible Zod conversion: Write custom transformers to modify Protobuf definitions after conversion.
Installation
Bun
bun add @czfabrics/[email protected]Yarn
yarn add @czfabrics/[email protected]NPM
npm install @czfabrics/[email protected]Quick Start
import { UnscopedMessage, zodToProto } from '@czfabrics/zod-to-proto'
import z from 'zod'
const User = z.object({
id: z.int64(),
fullName: z.string().optional(),
role: z.enum(['ADMIN', 'VIEWER']),
})
const result = zodToProto({
syntax: 'proto3',
packageName: 'services.authentication.v1',
services: [],
unscopedMessages: {
user: UnscopedMessage.new('OUT', User),
},
})Result:
syntax = "proto3";
import "buf/validate/validate.proto";
package services.authentication.v1;
enum UserRole {
ADMIN = 0;
VIEWER = 1;
}
message User {
int64 id = 1 [
(buf.validate.field).required = true
];
optional string full_name = 2;
UserRole role = 3 [
(buf.validate.field).required = true
];
}Usage
Basic
import { UnscopedMessage, zodToProto } from '@czfabrics/zod-to-proto'
import z from 'zod'
const User = z.object({
id: z.int64(),
fullName: z.string().optional(),
role: z.enum(['ADMIN', 'VIEWER']),
})
const result = zodToProto({
syntax: 'proto3',
packageName: 'services.authentication.v1',
services: [],
unscopedMessages: {
user: UnscopedMessage.new('OUT', User),
},
})Result:
syntax = "proto3";
import "buf/validate/validate.proto";
package services.authentication.v1;
enum UserRole {
ADMIN = 0;
VIEWER = 1;
}
message User {
int64 id = 1 [
(buf.validate.field).required = true
];
optional string full_name = 2;
UserRole role = 3 [
(buf.validate.field).required = true
];
}gRPC Service
import { MessageOut, zodToProto } from '@czfabrics/zod-to-proto'
import z from 'zod'
const User = z.object({
id: z.int64(),
fullName: z.string().optional(),
role: z.enum(['ADMIN', 'VIEWER']),
})
const result = zodToProto({
syntax: 'proto3',
packageName: 'services.user.v1',
services: [
{
name: 'UserService',
functions: [
{
name: 'GetUsers',
in: undefined,
inStream: false,
out: MessageOut.new(
z.object({
users: z.array(User),
})
),
outStream: true,
},
],
},
],
})Result:
syntax = "proto3";
import "google/protobuf/empty.proto";
import "buf/validate/validate.proto";
package services.user.v1;
service UserService {
rpc GetUsers(google.protobuf.Empty) returns (stream GetUsersOutput) {}
}
enum UserRole {
ADMIN = 0;
VIEWER = 1;
}
message GetUsersOutputUser {
int64 id = 1 [
(buf.validate.field).required = true
];
optional string full_name = 2;
UserRole role = 3 [
(buf.validate.field).required = true
];
}
message GetUsersOutput {
repeated GetUsersOutputUser users = 1 [
(buf.validate.field).required = true
];
}gRPC Service with gRPC gateway annotations
import { MessageIn, Proto3HttpAnnotation, zodToProto } from '@czfabrics/zod-to-proto'
import z from 'zod'
const User = z.object({
id: z.int64(),
fullName: z.string().optional(),
role: z.enum(['ADMIN', 'VIEWER']),
})
const result = zodToProto({
syntax: 'proto3',
packageName: 'services.user.v1',
services: [
{
name: 'UserService',
functions: [
{
name: 'AddUser',
in: MessageIn.new(
z.object({
user: User.omit({ id: true }),
})
),
extensions: [
Proto3HttpAnnotation.useExtension({
post: '/users',
body: '*',
}),
],
},
],
},
],
})Result:
syntax = "proto3";
import "buf/validate/validate.proto";
import "google/protobuf/empty.proto";
import "google/api/annotations.proto";
package services.user.v1;
service UserService {
rpc AddUser(AddUserInput) returns (google.protobuf.Empty) {
option (google.api.http) = {
post: "/users",
body: "*"
};
}
}
enum UserRole {
ADMIN = 0;
VIEWER = 1;
}
message AddUserInputUser {
optional string full_name = 1;
UserRole role = 2 [
(buf.validate.field).required = true
];
}
message AddUserInput {
AddUserInputUser user = 1 [
(buf.validate.field).required = true
];
}Type prefix
Three levels of type prefix: file, service, function.
import { MessageOut, zodToProto } from '@czfabrics/zod-to-proto'
import z from 'zod'
const User = z.object({
id: z.int64(),
fullName: z.string().optional(),
role: z.enum(['ADMIN', 'VIEWER']),
})
const User2 = z.object({
id: z.int64(),
fullName: z.string().optional(),
role: z.enum(['ADMIN', 'VIEWER']),
})
const result = zodToProto({
syntax: 'proto3',
packageName: 'services.user.v1',
typePrefix: 'UserPackage', // First level prefix
services: [
{
name: 'UserService',
typePrefix: 'UserService', // Second level prefix
functions: [
{
name: 'GetUsers',
typePrefix: 'GetUsers', // Third level prefix
out: MessageOut.new(
z.object({
users: z.array(User),
})
),
},
],
},
{
name: 'UserService2',
typePrefix: 'UserService2',
functions: [
{
name: 'GetUsers',
typePrefix: 'GetUsers',
out: MessageOut.new(
z.object({
users: z.array(User2),
})
),,
},
],
},
],
})Result:
syntax = "proto3";
import "google/protobuf/empty.proto";
import "buf/validate/validate.proto";
package services.user.v1;
service UserService {
rpc GetUsers(google.protobuf.Empty) returns (UserPackageUserServiceGetUsersOutput) {}
}
service UserService2 {
rpc GetUsers(google.protobuf.Empty) returns (UserPackageUserService2GetUsersOutput) {}
}
enum UserPackageUserService2GetUsersUserRole {
ADMIN = 0;
VIEWER = 1;
}
message UserPackageUserService2GetUsersOutputUser {
int64 id = 1 [
(buf.validate.field).required = true
];
optional string full_name = 2;
UserPackageUserService2GetUsersUserRole role = 3 [
(buf.validate.field).required = true
];
}
message UserPackageUserService2GetUsersOutput {
repeated UserPackageUserService2GetUsersOutputUser users = 1 [
(buf.validate.field).required = true
];
}
enum UserPackageUserServiceGetUsersUserRole {
ADMIN = 0;
VIEWER = 1;
}
message UserPackageUserServiceGetUsersOutputUser {
int64 id = 1 [
(buf.validate.field).required = true
];
optional string full_name = 2;
UserPackageUserServiceGetUsersUserRole role = 3 [
(buf.validate.field).required = true
];
}
// UserPackage -> UserService -> GetUsers
message UserPackageUserServiceGetUsersOutput {
repeated UserPackageUserServiceGetUsersOutputUser users = 1 [
(buf.validate.field).required = true
];
}Enforced typecheck
You can safely check if your schema is compatible. It will trigger a TypeScript error. Note that deeper schemas may slow down the TSC compiler.
import { UnscopedMessage, zodToProto } from '@czfabrics/zod-to-proto'
import z from 'zod'
const User = z.object({
id: z.int64(),
fullName: z.string().optional(),
role: z.enum(['ADMIN', 'VIEWER']),
})
const result = zodToProto({
syntax: 'proto3',
packageName: 'services.authentification.v1',
services: [],
unscopedMessages: {
user: UnscopedMessage.safeNew('OUT', User), // => No TS error because it's compatible
user2: UnscopedMessage.safeNew(
'OUT',
z.object({
createdAt: z.date(),
// => TypeDebuggingError<"This Zod type 'date' is not supported">
//
// **Note:** You can make the schema compatible by using z.string().pipe(z.coerce.date()) or a ZodCodec.
// This will result in the following proto field: `string created_at = 1;`
})
),
user3: UnscopedMessage.safeNew(
'OUT',
z.object({
createdAt: z.codec(z.date(), z.iso.datetime(), {
decode: (date) => date.toISOString(),
encode: (isoString) => new Date(isoString),
}),
// => No TS error because it will take the 'out' schema of the Codec: `z.iso.datetime`
})
),
user4: UnscopedMessage.safeNew(
'IN',
z.object({
createdAt: z.codec(z.iso.datetime(), z.date(), {
decode: (isoString) => new Date(isoString),
encode: (date) => date.toISOString(),
}),
// => No TS error because it will take the 'in' schema of the Codec: `z.iso.datetime`
})
),
},
})Note that you can utilize the type behind the UnscopedMessage.safeNew method in your own functions.
Example of usage:
import { CheckZodSchemaCompatibility, ZodPassthroughDirection } from '@czfabrics/zod-to-proto'
import { SomeType } from 'zod/v4/core'
export const safeNew = function <
const TDirection extends ZodPassthroughDirection,
const TSchema extends SomeType,
>(direction: TDirection, schema: CheckZodSchemaCompatibility<TDirection, TSchema>) {
return schema
}Extension
import {
MessageOut,
Proto3Deprecated,
Proto3HttpAnnotation,
zodToProto,
} from '@czfabrics/zod-to-proto'
import z from 'zod'
const User = z.object({
id: z.int64(),
fullName: z.string().optional(),
role: z.enum(['ADMIN', 'VIEWER']),
})
const result = zodToProto({
syntax: 'proto3',
packageName: 'services.authentification.v1',
services: [
{
name: 'UserService',
functions: [
{
name: 'GetUsers',
out: MessageOut.new(
z.object({
users: z.array(User),
})
),
outStream: true,
extensions: [
//// google.api.http option for gRPC restful gateway
Proto3HttpAnnotation.useExtension({
get: '/users',
}),
],
},
],
extensions: [
//// Global option
Proto3Deprecated.useExtension(true),
],
},
],
})Result:
syntax = "proto3";
import "google/protobuf/empty.proto";
import "buf/validate/validate.proto";
import "google/api/annotations.proto";
package services.authentification.v1;
service UserService {
option (deprecated) = true;
rpc GetUsers(google.protobuf.Empty) returns (stream GetUsersOutput) {
option (google.api.http).get = "/users";
}
}
enum UserRole {
ADMIN = 0;
VIEWER = 1;
}
message User {
int64 id = 1 [
(buf.validate.field).required = true
];
optional string full_name = 2;
UserRole role = 3 [
(buf.validate.field).required = true
];
}
message GetUsersOutput {
repeated User users = 1 [
(buf.validate.field).required = true
];
}Compatibility
Zod
| Zod Type | Interpreted as | Notice |
| ----------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Primitif Types | | |
| ZodString | string | |
| ZodStringFormat | string | Format is ignored, but can be handled by a transformer. |
| ZodLiteral | string | Value is ignored, but can be handled by a transformer. |
| ZodTemplateLiteral | string | Template is ignored, but can be handled by a transformer. |
| ZodNumber | double | |
| ZodNumberFormat | int32float32float64uint32 | The conversion result depends on the format of the ZodNumberFormat.(Format safeint is interpreted as int64) |
| ZodBigInt | int64uint64 | Depends on the format. |
| ZodBoolean | bool | |
| ~~ZodDate~~ | Not handled | Use a ZodCodec with a string input and convert it to a JS Date instead. |
| Structure Types | | |
| ZodObject | message Some {} | |
| ZodEnum | enum Some {} | |
| ZodRecord | map<{key type}, {value type}> | Not all Zod types are supported due to Proto limitations.Keys can be an integer or a string.Values can be any types except array or another map. |
| ~~ZodMap~~ | Not handled | Use ZodRecord instead. |
| ZodArray | repeated {} | Not all Zod types are supported due to Proto limitations.Elements can be any types except map. |
| ZodSet | repeated {} | Not all Zod types are supported due to Proto limitations.Elements can be any types except map. |
| Other Types | | |
| ZodOptional | optional {some_type} my_field = 1 | Internally uses Zod's safeParse to determine if the schema is optional. |
| ZodNonOptional | {some_type} my_field = 1 [(buf.validate.field).required = true]; | Internally uses Zod's safeParse to determine if the schema is optional. |
| ZodIntersection | message Some {} | Only works with ZodObjectUses the left ZodObject to extend the right one. |
| ZodPipe | | The ZodPipe is transparent—it just passes through the input value depending on the direction (IN or OUT). |
| ZodCodec | | Same as ZodPipe. |
| ZodTransform | | Same as ZodPipe. |
| ZodPrefault | | Same as ZodPipe. |
| ZodLazy | | Same as ZodPipe. |
| ZodCatch | | Same as ZodPipe. |
| ZodReadonly | | Same as ZodPipe. |
| ZodDefault | | Same as ZodPipe. |
| Special Types | | |
| ZodDiscriminatedUnion | oneof my_field {} | Not all cases are handled; use pz.oneOfUnion().This method builds a ZodDiscriminatedUnion that matches the ts-proto feature oneof=unions-value type. |
| ~~ZodUnion~~ | Not handled | Use ZodOneOfUnion instead. |
Note: Other Zod types are not handled.
Proto3
| Feature | Type |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| File | Proto3File |
| Imports | Proto3ImportedType |
| RPC service | Proto3RpcService |
| RPC function | Proto3RpcFunction |
| Message | Proto3Message |
| Basic field | Proto3MessageField |
| OneOf field | Proto3MessageOneOfField |
| Enum | Proto3Enum |
| Enum field | Proto3EnumField |
| Scalar type | Proto3StringTypeProto3BoolTypeProto3Int32TypeProto3Int64TypeProto3UInt32TypeProto3UInt64TypeProto3SInt32TypeProto3SInt64TypeProto3Fixed32TypeProto3Fixed64TypeProto3SFixed32TypeProto3SFixed64TypeProto3DoubleTypeProto3FloatTypeProto3BytesType |
| Repeated | Proto3RepeatedType |
| Map | Proto3MapType |
| Global option (like deprecated) | Proto3GlobalType |
Protovalidate
| Annotation | Type | Notice |
| -------------------- | ------------------------------- | ----------------------------------------- |
| buf.validate.field | Proto3ValidateFieldAnnotation | Only the required parameter is handled. |
| buf.validate.oneof | Proto3ValidateFieldAnnotation | Only the required parameter is handled. |
| Annotation/Type | Type |
| ----------------------- | ---------------------- |
| google.api.http | Proto3HttpAnnotation |
| google.protobuf.Empty | Proto3Empty |
Plugin
Custom Type
import { Proto3ImportedType } from '@czfabrics/zod-to-proto'
export const Proto3Empty = {
useType: () => {
return Proto3ImportedType.new({
importPath: 'google/protobuf/empty.proto',
typeReference: 'google.protobuf.Empty',
})
},
} as constCustom Extension
import { Proto3Extension, Proto3ImportedType } from '@czfabrics/zod-to-proto'
export const Proto3ValidateFieldAnnotation = {
useType: function () {
return Proto3ImportedType.new({
importPath: 'buf/validate/validate.proto',
typeReference: 'buf.validate.field',
})
},
useExtension: function (value: { required?: boolean }) {
return Proto3Extension.new({
key: this.useType(),
value,
})
},
} as constZod Conversion Transformer
This package allows you to write custom transformers to modify Protobuf definitions after converting from Zod schemas.
Available transformers:
ZodDeprecatedMessageConversionTransformerZodMessageNameIncludedInFieldConversionTransformerZodEnumNameIncludedInFieldConversionTransformerZodMessageNameConversionTransformerZodDeprecatedFieldConversionTransformerZodRequiredFieldConversionTransformerZodDeprecatedOneOfFieldConversionTransformerZodRequiredOneOfFieldConversionTransformerZodDeprecatedEnumConversionTransformerZodEnumNameConversionTransformer
Writing a transformer
import {
isZodSchemaDeprecated,
Proto3Deprecated,
WithMaybeZodPassthrough,
ZodConversionContext,
ZodMessageFieldConversionTransformer,
ZodMessageFieldType,
type ReadOnlyProto3MessageField,
} from '@czfabrics/zod-to-proto'
export class ZodDeprecatedFieldConversionTransformer implements ZodMessageFieldConversionTransformer {
transform(
context: ZodConversionContext,
schema: WithMaybeZodPassthrough<ZodMessageFieldType>,
protoDefinition: ReadOnlyProto3MessageField
): ReadOnlyProto3MessageField {
const isDeprecated = isZodSchemaDeprecated(context, schema)
if (!isDeprecated) {
return protoDefinition
}
return protoDefinition.clone({
extensions: [
...protoDefinition.extensions,
Proto3Deprecated.useExtension(true),
],
})
}
}Using a Transformer
zodToProto(
<...>,
{
transformers: {
message: [],
messageField: [new ZodDeprecatedFieldConversionTransformer()],
messageOneOfField: [],
enum: [],
},
}
)License
Licensed under MIT.

