zod-to-amplify-dsl
v0.2.0
Published
Convert Zod schemas to AWS Amplify Gen 2 DSL
Maintainers
Readme
zod-to-amplify-dsl
Convert Zod v4 schemas to AWS Amplify Gen 2 TypeScript DSL.
Target: Amplify Gen 2 — generates code for
@aws-amplify/data-schemav1.x. Amplify Gen 1 (GraphQL SDL /@modeldirectives) is not supported.
Install
npm add zod-to-amplify-dsl
# or
pnpm add zod-to-amplify-dslQuick start
npx zod-to-amplify init # create starter schema.ts + zod-amplify.config.ts
npx zod-to-amplify --dry # preview generated output
npx zod-to-amplify # write to amplify/data/resource.tsCLI
zod-to-amplify [options]
zod-to-amplify watch [options]
zod-to-amplify init [--force]zod-to-amplify (generate)
| Flag | Alias | Default | Description |
|---|---|---|---|
| --input <file> | -i | schema.ts | TypeScript file exporting Zod models |
| --output <file> | -o | amplify/data/resource.ts | Output file path |
| --dry | | false | Print output to stdout without writing |
| --check | | false | Verify output matches what's on disk; exit 1 on drift (CI) |
| --json | | false | Output JSON schema metadata instead of TypeScript |
--checkregenerates in memory and compares against the committed files (including the generated storage file). It writes nothing and exits non-zero when anything is missing or stale — handy as a CI guard against forgetting to re-run the generator after editing the schema.
zod-to-amplify watch
Same flags as generate. Watches the input file and regenerates on every save.
zod-to-amplify init
Creates schema.ts and zod-amplify.config.ts in the current directory.
| Flag | Description |
|---|---|
| --force | Overwrite existing files |
zod-to-amplify mcp
Starts an MCP server over stdio so an AI agent
can run the converter as a tool. Register it with your MCP client via npx:
{
"mcpServers": {
"zod-to-amplify": {
"command": "npx",
"args": ["-y", "zod-to-amplify-dsl", "mcp"]
}
}
}Exposed tools (read-only — they never write files):
| Tool | Input | Returns |
|---|---|---|
| usage | (none) | A guide on how to write a schema file and use the tools |
| convert_schema | { schemaPath } | Generated Amplify DSL (with storage code + warnings appended as comments) |
| schema_summary | { schemaPath } | JSON summary (zodToAmplifyMeta) |
schemaPath is a .ts file exporting Zod models, resolved relative to the
server's working directory.
Config file
Create zod-amplify.config.ts in your project root (optional — CLI flags take precedence):
import { defineConfig } from "zod-to-amplify-dsl"
export default defineConfig({
input: "src/schema.ts",
output: "amplify/data/resource.ts",
// storageOutput: "amplify/storage/resource.ts", // default: sibling of `output`
// storageName: "media", // defineStorage({ name })
})Schema file
Export Zod models from the input file. Use getter syntax to define circular/forward references between models.
// schema.ts
import { z } from "zod"
import { defineModel } from "zod-to-amplify-dsl"
export const Post = defineModel(
z.object({
id: z.uuid(),
title: z.string().max(200),
status: z.enum(["DRAFT", "PUBLISHED"]),
authorId: z.string(),
createdAt: z.iso.datetime(),
// Relations: use getter to avoid circular reference issues
get author(): z.ZodObject<any> { return User },
get comments(): z.ZodArray<z.ZodObject<any>> { return z.array(Comment) },
get tags(): z.ZodArray<z.ZodObject<any>> { return z.array(Tag) },
}),
{
indexes: [{ name: "byAuthor", pk: "authorId", sk: "createdAt" }],
auth: [
{ allow: "owner", ownerField: "authorId" },
{ allow: "public", operations: ["read"] },
],
}
)
export const User = z.object({
id: z.uuid(),
name: z.string(),
email: z.email(),
get posts(): z.ZodArray<z.ZodObject<any>> { return z.array(Post) },
})
export const Comment = z.object({
id: z.string(),
body: z.string(),
postId: z.string(),
get post(): z.ZodObject<any> { return Post },
})
// Mutual array references → junction model (PostTag) is auto-generated
export const Tag = z.object({
id: z.string(),
name: z.string(),
get posts(): z.ZodArray<z.ZodObject<any>> { return z.array(Post) },
})
z.lazy(() => Model)also works as an alternative to getter syntax.
Programmatic API
zodToAmplify(models)
Returns { code: string, warnings: ConversionWarning[] }.
import { z } from "zod"
import { zodToAmplify } from "zod-to-amplify-dsl"
const Post = z.object({ id: z.string(), title: z.string() })
const { code, warnings } = zodToAmplify({ Post })
if (warnings.length > 0) {
console.warn("Unsupported types:", warnings)
}
console.log(code)zodToAmplifyMeta(models)
Returns a JSON-serializable SchemaSummary — useful for tooling or validation.
import { zodToAmplifyMeta } from "zod-to-amplify-dsl"
const meta = zodToAmplifyMeta({ Post, User })
// meta.models[].fields, .relations, .primaryKey, .indexes, .auth
// meta.customTypes[].fields
// meta.warnings
// meta.storage → [{ path, access }]Storage (S3) fields
Amplify Gen 2 has no native file/image data type — files live in S3 and the data
model only stores the S3 key. Wrap a string field with storageField() to
mark it: the field becomes a.string() in data/resource.ts, and a separate
amplify/storage/resource.ts is generated with a matching defineStorage.
import { z } from "zod"
import { storageField } from "zod-to-amplify-dsl"
export const Post = z.object({
id: z.uuid(),
coverImage: storageField(z.string(), {
path: "media/posts/*",
access: [
{ allow: "guest", to: ["read"] },
{ allow: "owner", to: ["read", "write", "delete"] },
],
}).optional(),
})Generated data/resource.ts (excerpt):
Post: a.model({
id: a.id(),
coverImage: a.string(), // zod: storage(path="media/posts/*")
})Generated amplify/storage/resource.ts:
import { defineStorage } from "@aws-amplify/backend"
export const storage = defineStorage({
name: "media",
access: (allow) => ({
"media/posts/*": [
allow.guest.to(["read"]),
allow.entity("identity").to(["read", "write", "delete"]),
],
}),
})Notes
accessis optional. When omitted it defaults to a secureallow.authenticated.to(["read", "write", "delete"])(no guest access).- Fields sharing the same
pathhave their access rules merged and de-duplicated. - Allow kinds map as:
guest→allow.guest,authenticated→allow.authenticated,owner→allow.entity("identity"),groups→allow.groups([...]). - Output location: a
data/resource.tsoutput emitsstorage/resource.tsalongside it; override withstorageOutput(and the bucket name withstorageName) in the config file. The CLI writes the storage file only when at least onestorageField()is present.
Type mapping
Scalars
| Zod | Amplify | Notes |
|---|---|---|
| z.string() | a.string() | |
| z.uuid() | a.id() | legacy z.string().uuid(); also any field named *Id |
| z.email() | a.email() | legacy z.string().email() |
| z.url() | a.url() | legacy z.string().url() |
| z.e164() | a.phone() | E.164 phone number; legacy z.string().e164() |
| z.ipv4() / z.ipv6() | a.ipAddress() | legacy z.string().ipv4() / .ipv6() |
| z.iso.datetime() | a.datetime() | legacy z.string().datetime() |
| z.iso.date() | a.date() | date only; legacy z.string().date() |
| z.iso.time() | a.time() | time only; legacy z.string().time() |
| z.number() / z.float32() / z.float64() | a.float() | |
| z.int() / z.int32() / z.number().int() | a.integer() | |
| z.boolean() | a.boolean() | |
| z.date() | a.datetime() | |
| z.any() / z.unknown() | a.json() | intentional — no warning |
| z.record() / z.tuple() | a.json() | intentional — no warning |
| z.map() / z.set() / z.bigint() | a.json() | with warning (no faithful representation) |
| other | a.json() | with warning |
Enums (hoisted to schema level)
Amplify's a.enum() cannot be used inline in model fields. All enum types are hoisted to the schema level and referenced via a.ref().
| Zod | Generated field | Schema-level entry |
|---|---|---|
| z.enum(["A", "B"]) | field: a.ref("Field").required() | Field: a.enum(["A", "B"]) |
| z.literal("active") | status: a.ref("Status").required() | Status: a.enum(["active"]) |
| z.union([z.literal("A"), z.literal("B")]) | kind: a.ref("Kind").required() | Kind: a.enum(["A", "B"]) |
Enums with .default() emit a comment instead of the unsupported chain:
// Zod: status: z.enum(["draft", "published"]).default("draft")
// Generated:
status: a.ref("Status"), // zod: default("draft")Optional / default
| Zod | Amplify |
|---|---|
| z.string().optional() | a.string() (no .required()) |
| z.string().default("x") | a.string().default("x") |
| z.string().nullable() | a.string() (nullable treated as optional) |
Scalar arrays
| Zod | Amplify |
|---|---|
| z.array(z.string()) | a.string().array().required() |
| z.array(z.number().int()) | a.integer().array().required() |
| z.array(z.enum([...])) | a.ref("Name").array().required() |
Nested objects (customType)
Non-model z.object() fields are emitted as a.customType():
const Address = z.object({ street: z.string(), city: z.string() })
const User = z.object({ id: z.string(), address: Address })Generated:
User: a.model({
id: a.id(),
address: a.ref("Address").required(),
}),
Address: a.customType({
street: a.string().required(),
city: a.string().required(),
}),Relations
| Pattern | Amplify |
|---|---|
| get posts() { return z.array(Post) } | a.hasMany("Post", "userId") |
| get author() { return User } + FK field userId | a.belongsTo("User", "userId") |
| get profile() { return Profile } (no FK on this side) | a.hasOne("Profile", "userId") |
| Mutual z.array() on both sides | a.hasMany("AJunctionModel", "fkId") + auto junction model |
manyToMany — Amplify Gen 2 has no a.manyToMany(). When both models have z.array() pointing at each other, a junction model is automatically generated:
// Input: Post.tags ↔ Tag.posts
// Generated:
Post: a.model({ tags: a.hasMany("PostTag", "postId"), ... }),
Tag: a.model({ posts: a.hasMany("PostTag", "tagId"), ... }),
PostTag: a.model({
postId: a.id().required(),
tagId: a.id().required(),
post: a.belongsTo("Post", "postId"),
tag: a.belongsTo("Tag", "tagId"),
}),defineModel options
defineModel(zodSchema, {
// Composite primary key → .identifier([...])
primaryKey: ["tenantId", "orderId"],
// Secondary indexes → .secondaryIndexes(...)
indexes: [
{ name: "byAuthor", pk: "authorId" },
{ name: "byAuthorDate", pk: "authorId", sk: "createdAt" },
// queryField → a custom list query: .queryField("listByAuthor")
{ name: "byAuthor2", pk: "authorId", queryField: "listByAuthor" },
],
// Authorization rules → .authorization(...)
auth: [
{ allow: "owner" },
{ allow: "owner", ownerField: "authorId" }, // custom owner field
{ allow: "public", operations: ["read"] },
{ allow: "groups", groups: ["admin", "editor"], operations: ["create", "update"] },
],
// Per-field authorization → field.authorization(allow => [...])
fieldAuth: {
ssn: [{ allow: "owner" }],
},
// Disable generated operations → .disableOperations([...])
disabledOperations: ["delete", "subscriptions"],
})disabledOperations accepts: queries, mutations, subscriptions, list,
get, create, update, delete, onCreate, onUpdate, onDelete.
Auth mapping:
| Rule | Generated |
|---|---|
| { allow: "owner" } | allow.owner() |
| { allow: "owner", ownerField: "f" } | allow.ownerDefinedIn("f") |
| { allow: "multipleOwners", ownersField: "f" } | allow.ownersDefinedIn("f") |
| { allow: "public" } | allow.publicApiKey() |
| { allow: "public", operations: ["read"] } | allow.publicApiKey().to(["read"]) |
| { allow: "guest" } | allow.guest() |
| { allow: "authenticated" } | allow.authenticated() |
| { allow: "group", group: "g" } | allow.group("g") |
| { allow: "groups", groups: ["g"] } | allow.groups(["g"]) |
| { allow: "custom" } | allow.custom() (Lambda authorizer) |
All rules accept operations (mapped to .to([...])). Owner/group rules accept an
optional provider ("userPools" | "oidc"); authenticated also accepts
"identityPool". Example: { allow: "authenticated", provider: "oidc", operations: ["read"] }
→ allow.authenticated("oidc").to(["read"]).
Field validation
Zod constraints on string / integer / float fields are emitted as Amplify
field-level .validate()
chains:
// z.string().min(1).max(200) →
title: a.string().validate((v) => v.minLength(1).maxLength(200)).required(),
// z.string().regex(/^[a-z-]+$/) →
slug: a.string().validate((v) => v.matches("^[a-z-]+$")).required(),
// z.number().min(0).max(100) → inclusive bounds use gte/lte
score: a.float().validate((v) => v.gte(0).lte(100)).required(),
// z.number().gt(0).lt(1) → exclusive bounds use gt/lt
ratio: a.float().validate((v) => v.gt(0).lt(1)).required(),Mapping: min/max (string) → minLength/maxLength, regex → matches,
startsWith/endsWith → same; min/max (number) → gte/lte, gt/lt → gt/lt.
Amplify only allows .validate() on a.string(), a.integer(), and a.float().
For other types (e.g. a.email() with a length constraint) the constraints are
preserved as an inline comment instead:
email: a.email().required(), // zod: maxLength(50)Auto-managed fields
createdAt and updatedAt are managed by Amplify. They are emitted without .required() regardless of the Zod schema:
createdAt: a.datetime(),
updatedAt: a.datetime(),License
MIT
