Skip to main content

@globalart/zod-to-proto

A library for converting Zod schemas to Protobuf definitions. Automatically generates .proto files from Zod validation schemas, including support for gRPC services.

Installation​

npm install @globalart/zod-to-proto zod

Overview​

The @globalart/zod-to-proto package provides a convenient way to generate Protobuf definitions from Zod schemas, enabling:

  • Type-safe schemas - Use Zod for validation and automatically generate Protobuf definitions
  • gRPC support - Generate complete gRPC service definitions from Zod schemas
  • Multiple services - Define multiple services in a single protobuf file
  • TypeScript integration - Full TypeScript support with proper types

Key Features​

  • Schema conversion - Convert Zod schemas to Protobuf messages
  • gRPC services - Generate gRPC service definitions from Zod function schemas
  • Multiple formats - Support for arrays, maps, records, enums, and nested objects
  • Type safety - Full TypeScript support
  • Flexible configuration - Customize package names, message names, and type prefixes

Quick Start​

Basic Usage​

import { zodToProtobufService } from "@globalart/zod-to-proto";
import { z } from "zod";

const userSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});

const protoDefinition = zodToProtobufService({
packageName: "user.service",
services: {
UserService: z.object({
getUser: z.function({
input: [z.object({ id: z.number().int() })],
output: userSchema,
}),
}),
},
});

console.log(protoDefinition);

Output:

syntax = "proto3";
package user.service;

message GetUserRequest {
int32 id = 1;
}

message GetUserResponse {
string name = 1;
int32 age = 2;
string email = 3;
}

service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

Generating gRPC Services​

There are two ways to define gRPC services:

import { zodToProtobufService } from "@globalart/zod-to-proto";
import { z } from "zod";

const getUserByIdRequestSchema = z.object({
id: z.number().int(),
});

const userSchema = z.object({
id: z.number().int(),
name: z.string(),
email: z.string(),
});

const userServiceSchema = z.object({
getUserById: z.function({
input: [getUserByIdRequestSchema],
output: userSchema,
}),
createUser: z.function({
input: [userSchema],
output: z.object({
id: z.number().int(),
success: z.boolean(),
}),
}),
});

const protoDefinition = zodToProtobufService({
packageName: "user.service",
services: {
UserService: userServiceSchema,
},
});

Option 2: Using Service Definitions Array​

import { zodToProtobufService } from "@globalart/zod-to-proto";
import { z } from "zod";

const protoDefinition = zodToProtobufService({
packageName: "user.service",
services: [
{
name: "UserService",
methods: [
{
name: "getUser",
request: z.object({
id: z.string(),
}),
response: z.object({
name: z.string(),
age: z.number(),
email: z.string(),
}),
},
{
name: "createUser",
request: z.object({
name: z.string(),
age: z.number(),
email: z.string(),
}),
response: z.object({
id: z.string(),
success: z.boolean(),
}),
},
],
},
],
});

Both approaches produce the same output:

syntax = "proto3";
package user.service;

message GetUserByIdRequest {
int32 id = 1;
}

message GetUserByIdResponse {
int32 id = 1;
string name = 2;
string email = 3;
}

message CreateUserRequest {
int32 id = 1;
string name = 2;
string email = 3;
}

message CreateUserResponse {
int32 id = 1;
bool success = 2;
}

service UserService {
rpc GetUserById(GetUserByIdRequest) returns (GetUserByIdResponse);
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
}

Configuration​

ZodToProtobufOptions​

OptionTypeRequiredDescription
packageNamestringNoProtobuf package name (default: "default")
typePrefixstringNoPrefix for type names
servicesServicesInputNogRPC service definitions
warningDeclarationbooleanNoAdd warning comment at the beginning of the file (default: true)
skipRootMessagebooleanNoSkip root message generation

Service Definition​

You can define services in two ways:

Option 1: Using Zod Schemas (Recommended)

const serviceSchema = z.object({
methodName: z.function({
input: [requestSchema],
output: responseSchema,
}),
});

const proto = zodToProtobufService({
services: {
ServiceName: serviceSchema,
},
});

Option 2: Using Service Definitions Array

interface ServiceDefinition {
name: string;
methods: ServiceMethod[];
}

interface ServiceMethod {
name: string;
request: ZodTypeAny;
response: ZodTypeAny;
streaming?: "client" | "server" | "bidirectional";
}

Usage​

With Custom Type Prefix​

const protoDefinition = zodToProtobufService({
packageName: "api.v1",
typePrefix: "ApiV1",
services: {
UserService: userServiceSchema,
},
});

With Streaming Methods​

Streaming methods are defined through metadata in the Zod schema:

const chatServiceSchema = z.object({
sendMessage: z.function({
input: [z.object({ message: z.string() })],
output: z.object({ success: z.boolean() }),
}),
streamMessages: z
.function({
input: [z.object({ roomId: z.string() })],
output: z.object({ message: z.string() }),
})
.meta({ streaming: "server" }),
chat: z
.function({
input: [z.object({ message: z.string() })],
output: z.object({ message: z.string() }),
})
.meta({ streaming: "bidirectional" }),
});

const protoDefinition = zodToProtobufService({
packageName: "chat.service",
services: {
ChatService: chatServiceSchema,
},
});

Or through service definitions array:

const protoDefinition = zodToProtobufService({
packageName: "chat.service",
services: [
{
name: "ChatService",
methods: [
{
name: "sendMessage",
request: z.object({ message: z.string() }),
response: z.object({ success: z.boolean() }),
},
{
name: "streamMessages",
request: z.object({ roomId: z.string() }),
response: z.object({ message: z.string() }),
streaming: "server",
},
{
name: "chat",
request: z.object({ message: z.string() }),
response: z.object({ message: z.string() }),
streaming: "bidirectional",
},
],
},
],
});

Output:

service ChatService {
rpc SendMessage(SendMessageRequest) returns (SendMessageResponse);
rpc StreamMessages(StreamMessagesRequest) returns (stream StreamMessagesResponse);
rpc Chat(stream ChatRequest) returns (stream ChatResponse);
}

Without Warning Declaration​

const protoDefinition = zodToProtobufService({
packageName: "example",
warningDeclaration: false,
services: {
ExampleService: exampleServiceSchema,
},
});

API Reference​

zodToProtobufService​

Converts Zod schemas to a Protobuf definition.

Parameters:

  • options (optional): ZodToProtobufOptions - Configuration options

Returns: string - Protobuf definition as a string

Example:

const protoDefinition = zodToProtobufService({
packageName: "user.service",
services: {
UserService: userServiceSchema,
},
});

Supported Zod Types​

Basic Types​

  • z.string() → string
  • z.number() → int32 (or double for numbers with float32/float64 format)
  • z.boolean() → bool
  • z.bigint() → int64
  • z.date() → string
  • Custom types for bytes (via Buffer/Uint8Array check)

Collections​

  • z.array() → repeated
  • z.set() → repeated
  • z.map() → map<keyType, valueType> (key must be int32, int64, string, or bool)
  • z.record() → map<keyType, valueType> (same as z.map())
  • z.tuple() → nested message

Complex Types​

  • z.object() → nested message
  • z.enum() → enum
  • z.optional() → optional
  • z.nullable() → optional

Examples​

import { zodToProtobufService } from "@globalart/zod-to-proto";
import { z } from "zod";

const schema = z.object({
id: z.string(),
tags: z.array(z.string()),
metadata: z.map(z.string(), z.number()),
settings: z.record(z.string(), z.string()),
status: z.enum(["active", "inactive", "pending"]),
profile: z.object({
firstName: z.string(),
lastName: z.string(),
age: z.number().optional(),
}),
coordinates: z.tuple([z.number(), z.number()]),
});

const protoDefinition = zodToProtobufService({
packageName: "example",
services: {
ExampleService: z.object({
getData: z.function({
input: [z.object({ id: z.string() })],
output: schema,
}),
}),
},
});

Advanced Usage​

Working with Maps and Records​

Protobuf maps have strict key type requirements. Only integral types, strings, and booleans are allowed as keys. Both z.map() and z.record() are converted to protobuf map<> type:

const schema = z.object({
usersByStringId: z.map(z.string(), userSchema),
usersByIntId: z.map(z.number().int(), userSchema),
flagsMap: z.map(z.boolean(), z.string()),
metadataRecord: z.record(z.string(), z.string()),
settingsRecord: z.record(z.string(), z.number()),
});

Type-Safe Service Definitions​

When using Zod schemas for services, you get full TypeScript type safety:

const userServiceSchema = z.object({
getUser: z.function({
input: [z.object({ id: z.number().int() })],
output: userSchema,
}),
});

type UserService = z.infer<typeof userServiceSchema>;

class UserServiceImpl implements UserService {
async getUser({ id }: { id: number }) {
return { id, name: "John", email: "john@example.com" };
}
}

Avoiding Circular Dependencies​

Important: Avoid circular dependencies between schema files. Circular imports will cause types to become undefined during schema construction.

❌ Bad Example:

// user.schema.ts
import { getUserByIdResponseSchema } from "./get-user-by-id.schema";

export const userSchema = z.object({ ... });

export const userServiceSchema = z.object({
getUserById: z.function({
input: [...],
output: getUserByIdResponseSchema,
}),
});

// get-user-by-id.schema.ts
import { userSchema } from "./user.schema";

export const getUserByIdResponseSchema = userSchema;

✅ Good Example:

// user.schema.ts
import { getUserByIdRequestSchema } from "./get-user-by-id.schema";

export const userSchema = z.object({ ... });

export const userServiceSchema = z.object({
getUserById: z.function({
input: [getUserByIdRequestSchema],
output: userSchema,
}),
});

// get-user-by-id.schema.ts
export const getUserByIdRequestSchema = z.object({ id: z.number().int() });

Multiple Services​

You can define multiple services in a single protobuf file:

const protoDefinition = zodToProtobufService({
packageName: "api.v1",
services: {
UserService: userServiceSchema,
AuthService: authServiceSchema,
ProductService: productServiceSchema,
},
});

Nested Objects and Enums​

Nested objects are automatically converted to separate messages with names based on the parent context:

const schema = z.object({
user: z.object({
profile: z.object({
name: z.string(),
status: z.enum(["active", "inactive"]),
}),
}),
});

const protoDefinition = zodToProtobufService({
packageName: "example",
services: {
ExampleService: z.object({
getData: z.function({
input: [z.object({})],
output: schema,
}),
}),
},
});

Output:

message User {
UserProfile profile = 1;
}

message UserProfile {
string name = 1;
UserProfileStatus status = 2;
}

enum UserProfileStatus {
active = 0;
inactive = 1;
}

Limitations​

  • Only the types listed above are supported
  • Unsupported types will throw an exception
  • Nested objects are automatically converted to separate messages
  • Enum values are converted to numbers starting from 0
  • Map keys must be integral types (int32, int64, etc.), strings, or booleans - not doubles or floats
  • Avoid circular dependencies between schema files

Best Practices​

  • Use Zod schemas for services - Provides better type safety and maintainability
  • Avoid circular dependencies - Structure schemas to prevent circular imports
  • Use type prefixes - Helps organize types in large projects
  • Validate map keys - Ensure map keys are valid Protobuf key types
  • Organize schemas - Keep related schemas together and avoid circular references
  • Use metadata for streaming - Define streaming methods via .meta({ streaming: "..." }) for type safety