Generate zod schemas from Kotlin data classes.
Add the following to your build.gradle.kts:
plugins {
id("dev.zodable") version "1.7.5"
id("com.google.devtools.ksp") version "2.3.4" // Adjust version as needed
}And that's all! The plugin is ready to use.
Add the @Zodable annotation to your data classes. The plugin will generate a zod schema for each annotated class.
@Zodable
data class User(
val id: Int,
val name: String
)Build the schema with ./gradlew build, like you would with any other gradle project.
The generated schema will look like this:
import {z} from "zod"
export const UserSchema = z.object({
id: z.number().int(),
name: z.string()
})
export type User = z.infer<typeof UserSchema>Generated schemas can be found in build/zodable. It is a ready to use npm package.
Pydantic schema generation is also available, by setting enablePython to true in the gradle configuration (see
configuration options below). The generated schema will look like this:
from pydantic import BaseModel
class User(BaseModel):
id: int
name: strGenerated schemas can be found in build/pydantable. It is a ready to use pip package.
Zodable supports sealed classes and interfaces, generating discriminated union schemas keyed on a type field. The
discriminator value is taken from the @SerialName annotation, falling back to the class name.
@Serializable
@Zodable
sealed class Payload {
@SerialName("EMPTY")
data object EmptyPayload : Payload()
@SerialName("TEXT")
data class TextPayload(val text: String) : Payload()
}Generated TypeScript:
export const EmptyPayloadSchema = z.object({
type: z.literal("EMPTY")
})
export const TextPayloadSchema = z.object({
text: z.string(),
type: z.literal("TEXT")
})
export const PayloadSchema = z.discriminatedUnion("type", [
EmptyPayloadSchema,
TextPayloadSchema
])Nested sealed hierarchies are fully supported. Each intermediate sealed class gets its own discriminated union schema,
which is useful for validating subsets of the hierarchy. The top-level union is flattened to include all concrete leaf
types directly — this is required by Zod v3, whose z.discriminatedUnion only accepts z.object members.
@Serializable
@Zodable
sealed interface Notification {
sealed interface Push : Notification {
@SerialName("Apns")
data class Apns(val token: String) : Push
@SerialName("Fcm")
data class Fcm(val token: String) : Push
}
sealed interface Email : Notification {
@SerialName("Html")
data class Html(val address: String) : Email
@SerialName("Text")
data class Text(val address: String) : Email
}
}Generated TypeScript:
// Leaf schemas
export const ApnsSchema = z.object({ token: z.string(), type: z.literal("Apns") })
export const FcmSchema = z.object({ token: z.string(), type: z.literal("Fcm") })
// Intermediate union — useful when you only care about push notifications
export const PushSchema = z.discriminatedUnion("type", [ApnsSchema, FcmSchema])
export const HtmlSchema = z.object({ address: z.string(), type: z.literal("Html") })
export const TextSchema = z.object({ address: z.string(), type: z.literal("Text") })
export const EmailSchema = z.discriminatedUnion("type", [HtmlSchema, TextSchema])
// Top-level union is flat (all concrete types) due to Zod v3 requirements
export const NotificationSchema = z.discriminatedUnion("type", [
ApnsSchema, FcmSchema, HtmlSchema, TextSchema
])You can customize the generated schema with annotations:
You can ignore a field with the @ZodIgnore annotation:
data class User(
@ZodIgnore
val password: String // Will not be included in the generated schema
)If you are consuming another package/dependency in your schema, you can import it with the @ZodImport annotation:
@ZodImport("Pokemon", "my-pokemon-package") // Will generate import and package dependencies
data class User(
val favoritePokemon: Pokemon // Pokemon is defined in another gradle module/package
)You can specify the zod type for a field with the @ZodType annotation:
data class User(
@ZodType("IdSchema")
val id: UUID,
@ZodType("z.date()", "ts")
@ZodType("datetime", "py")
val birthDate: String,
)The first argument is the zod type, the second argument is a filter for the target language. If you defined a custom schema in another package or are only enabling one language, you can omit the filter.
Need the maximum flexibility? You can override the entire schema for a type with the @ZodOverrideSchema annotation:
@Zodable
@ZodOverrideSchema(
content = """
export const CustomSchema = z.object({
name: z.string(),
age: z.number().int(),
isActive: z.boolean(),
tags: z.array(z.string()),
})
export type Custom = z.infer<typeof CustomSchema>
""",
filter = "ts"
)
@ZodOverrideSchema(
content = """
from typing import List
class Custom(BaseModel):
name: str
age: int
is_active: bool
tags: List[str]
""",
filter = "py"
)
interface CustomYou can configure a few things in your build.gradle.kts:
zodable {
inferTypes = true // Generate `export type X = z.infer<typeof XSchema>`, default is true
optionals = dev.zodable.Optionals.NULLISH // How to handle optional fields, default is NULLISH
packageName = "my-package" // npm package name, default is the gradle project name
packageVersion = "1.0.0" // npm package version, default is the gradle project version
// additional npm commands to be executed to affect the generated zodable package
additionalNpmCommands = listOf(listOf("npm", "pkg", "set", "files[1]=.yalc/**/*"))
// mapping of @ZodImport package names to install commands
externalPackageInstallCommands = mapOf("package-name" to listOf("yalc", "add"))
// mapping of @ZodImport package names to locations
externalPackageLocations = mapOf("package-name" to "file:/path/to/package-name")
valueClassUnwrap = true // whether to unwrap properties with value class types, default is true
enableTypescript = true // Generate typescript schemas, default is true
enablePython = false // Generate pydantic schemas, default is false
}