Keywords
The DSL has 16 keywords plus the seven HTTP verbs. They are reserved - identifiers cannot use these names, with the contextual carve-outs noted below.
Declaration keywords
| Keyword | Position | Purpose |
|---|---|---|
package | first line | The package every declaration in this file belongs to |
type | top level | Declare a request / response struct |
enum | top level | Declare a closed value set |
error | top level | Declare a typed error with HTTP status mapping |
scalar | top level | Declare a named primitive with bundled validators |
service | top level | Declare an HTTP service |
extend | top level | Add methods to an existing service (extend service Name) |
middleware | top level | Declare a named middleware slot |
event | top level | Declare an event contract this package owns |
A service body holds HTTP methods only. event is a file-level declaration and there is no consumer declaration at all - which events a deployable listens to is Go code, written where its bus is built. See Events.
Member body keywords
| Keyword | Where | Purpose |
|---|---|---|
request | inside method body | Names the request type |
response | inside method body | Names the response type |
payload | inside event body | Names the type an event contract carries (or an array of it: Type[]) |
Type keywords
| Keyword | Where | Purpose |
|---|---|---|
map | type expression | Map type: map<string, int> |
Literal keywords
| Keyword | Purpose |
|---|---|
true | Boolean true literal |
false | Boolean false literal |
null | Null literal (used in some decorator args) |
Contextual use
A reserved word is still legal wherever the grammar leaves no ambiguity: as a field name in a type body, as an enum value name, as a decorator argument naming a field, and as a path segment or path-parameter name. type Msg { event string? payload string? } with @requiresOneOf(payload, event) above it is a valid design.
HTTP verbs
These behave like keywords inside a service body. They are also legal as identifiers in other positions (e.g. as an enum value name), so the parser disambiguates by context.
| Verb | Maps to |
|---|---|
get | GET |
post | POST |
put | PUT |
patch | PATCH |
delete | DELETE |
head | HEAD |
options | OPTIONS |
package
The first statement of every .craftgo file that declares anything - only comments and the file-level decorators @doc, @deprecated and @version may come before it. A file without one is rejected as package/missing at its first declaration:
package designFiles that declare the same package name form one package, whatever folder they sit in: declarations in design/users/service.craftgo and design/users/errors.craftgo see each other directly because both declare package design. The folder plays no part - two files in one folder that declare different names are two packages.
The package name does not need to match the folder name (though doing so reads cleaner).
Cross-package references
Reach a declaration from a different package by qualifying it with that package's name - no import statement is needed:
package design
type User {
contact shared.Contact
}craftgo resolves <pkg>.<Type> against every package in the project (the package X declaration is the name) and wires the matching Go import in the generated code automatically. Middleware names are global across the project; type / enum / error / scalar names live in their declaring package and must be qualified at the call site (shared.Audit, users.User).
Note: there is no import statement. A line starting with
importat file level is a parse error that says to reference the declaration aspkg.Typeand delete the line.importis not reserved, so a field may take it as its name.
type
Declare a request or response struct.
type CreateUserReq {
name string
email string @format(email)
}
type Page<T> { // generic - bare ident, no constraint
items T[]
total int
}See Types and Scalars.
enum
Declare a closed value set with three forms (bare / integer / string):
enum Status { Active Inactive Pending }
enum Priority { Low = 1 Medium = 2 High = 3 }
enum Color { Red = "red" Green = "green" Blue = "blue" }All values inside one enum must share the same form. Mixing them (e.g. Active = 1 Inactive = "off") raises enum/mixed-types. Enums are not generic - only type declarations support <T, U, ...> parameters.
See Enums.
scalar
Declare a named primitive with bundled validators:
scalar Email string @format(email) @maxLength(254)
scalar OrderID string @length(8, 64) @pattern("^ord_[A-Z0-9]+$")
scalar Cents int @gte(0) @multipleOf(2)
scalar Latitude float64 @gte(-90) @lte(90)The first form after the name is the underlying primitive (string, bytes, int, int8/16/32/64, uint, uint8/16/32/64, float32, float64, bool). Validators following the primitive inherit to every field of that scalar type.
See Types and Scalars.
error
Declare a typed error. Two forms:
error NotFound UserNotFound // empty body, 404
error Conflict EmailTaken { // body fields, 409
email string
existingId string?
}The first identifier after error is the HTTP category (one of 21 reserved names like BadRequest, NotFound, Conflict, Internal). The second is the error's name; its Go type is that name plus Err (UserNotFoundErr) unless it already ends in Err or Error, and a body block becomes the embedded <Name>Body struct. The optional body block carries fields that ride on the wire.
See Errors.
service
Declare an HTTP service:
@prefix("/v1")
@tags(users)
@middlewares(AuthRequired)
service UserService {
@doc("Get user by id.")
get GetUser /users/{id} {
request GetUserReq
response User
}
}The body holds zero or more method declarations. Method form: <verb> <Name> [<path>] { request <Type> response <Type> }. The request and response lines are optional (a method may have neither, only request, or only response), and so is the path: without one, the route is the method name in kebab case.
See DSL Basics for path parameters, decorators, and the full method shape.
extend service
Add methods to an existing service from a different file:
package design
extend service UserService {
@middlewares(AdminOnly)
delete PurgeUser /users/{id}/purge {
request GetUserReq
response shared.OkResp
}
}extend blocks may carry method-level-applicable decorators - any decorator a method takes, such as @middlewares, @security, @tags or @timeout - which propagate to every method inside, plus @group, which moves that block's methods into the group's directory. @prefix is primary-only, and @operationId belongs on each method. The extended service must already exist in the same package.
Used to split a large service across files, separate authenticated endpoints from public ones (the 50/50 pattern: primary holds public methods, an extend block holds the authenticated chain), or organise admin endpoints under a different middleware chain than the default. See DSL Basics for the full pattern.
middleware
Declare a named middleware slot:
middleware AuthRequired
middleware RateLimit
middleware CORSDeclared at file (package) level. Codegen produces a typed slot on ServiceContext and an empty stub at internal/middleware/<name>_middleware.go you fill in. Methods opt in via @middlewares(Name, ...). Middleware names are global across the project - @middlewares(AuthRequired) resolves the same regardless of package - because middleware represents runtime behavior, not data; type / enum / error / scalar names stay package-scoped and must be qualified across packages.
See Middleware.
event
Declare an event contract this package owns:
@doc("Emitted once an order is accepted.")
@contract("order.placed.v2")
event Placed {
payload OrderPlaced
}File level only - a service body holds HTTP methods. payload names a type declaration, so every contract has named fields and its own Validate(); payload OrderPlaced[] is a contract carrying a JSON array of that type. Events have their own namespace, so type OrderPlaced and event OrderPlaced coexist. The wire identity defaults to <package>.<Event> and @contract("...") overrides it. Codegen writes one constant and one descriptor per event; the listener side is ordinary Go against the bus.
See Events.
request and response
Used inside a method body to name the request and response types:
post CreateUser /users {
request CreateUserReq
response User
}Both are optional. Methods without request accept no body. Methods without response return an empty body with the configured status.
Each clause names a type. A bare array (Order[]) and a built-in primitive (string, bytes, any, ...) are rejected in both clauses - neither has the fields a request binds, and a primitive names no generated type at all - so wrap the value instead: type Resp { value string }. A scalar or an enum is rejected in request for that same fieldless reason, but is fine in response, where it generates a real named type.
map
Used inside a type expression for map types:
type Settings {
flags map<string, bool>
quotas map<string, int>
}The first generic argument is the key type and the second is the value type (any DSL type). The key must be a string- or integer-kind primitive (string, int*, uint*) or a scalar/enum over one - encoding/json can only marshal those as object keys. A bool, float*, or struct key is rejected at design time (it would compile but fail json.Marshal at runtime).
Reserved names you cannot use as identifiers
A reserved word cannot name a declaration: type event { ... } is a parse error (expected Ident, got event). In the positions under Contextual use it is fine: a field named type becomes the Go field Type with JSON key type.
File grammar in one shape
[@doc|@deprecated|@version]*
package <ident>
[<decl>]*
where <decl> is one of:
[@decorator]* type Name { fields... }
[@decorator]* type Name<T, U, ...> { fields... } // upper-case parameter names, no constraint
[@decorator]* enum Name { values... }
[@decorator]* error Category Name [{ fields... }]
[@decorator]* scalar Name <Primitive> [@validators...]
[@decorator]* service Name { methods... }
[@decorator]* extend service Name { methods... }
[@decorator]* middleware Name
[@decorator]* event Name { payload Type } // or `payload Type[]` for an array body
and a method is:
[@decorator]* <verb> Name [/path] { [request Type] [response Type] }