███████╗██╗░░░░░██╗░░░░░░█████╗░
██╔════╝██║░░░░░██║░░░░░██╔══██╗
█████╗░░██║░░░░░██║░░░░░███████║
██╔══╝░░██║░░░░░██║░░░░░██╔══██║
███████╗███████╗███████╗██║░░██║
╚══════╝╚══════╝╚══════╝╚═╝░░╚═╝
ella is a schema compiler that generates Go, TypeScript, and WebAssembly code from a single, human-readable definition language.
Ella takes .ella schema files and generates type-safe client/server code for Go, TypeScript clients and type definitions, and WASM bindings. Think of it like gRPC or Protocol Buffers, but with a much simpler syntax that reads like pseudocode.
You define your models, enums, services, and errors in one place, and ella generates everything you need to call those services from Go backends, TypeScript frontends, or browser WASM modules.
go install ella.to/ella@v0.3.1# Format .ella files in place
ella fmt "./schema/src/*.ella"
# Generate Go code
ella gen schema "./schema/output.gen.go" "./schema/src/*.ella"
# Generate Go code with WASM extensions (for browser clients)
ella gen schema --allow-ext "./schema/output.gen_js.go" "./schema/src/*.ella"
# Generate TypeScript type definitions (.d.ts)
ella gen schema "./web/src/schema.d.ts" "./schema/src/*.ella"
# Generate TypeScript runtime client (.ts)
ella gen schema "./web/src/schema.ts" "./schema/src/*.ella"
# Print AST for debugging
ella gen schema --debug "./schema/output.gen.go" "./schema/src/*.ella"
# Start the language server (for editor integration, speaks LSP over stdio)
ella lsp
# Print version
ella verThe output format is determined by the file extension of the output path:
.go— Go structs, interfaces, JSON-RPC client/server code_js.go— Go WASM bindings (use--allow-extflag).d.ts— TypeScript declarations (types/interfaces for WASM usage).ts— TypeScript runtime client (fetch JSON-RPC helper +create<Service>factories + models/enums)
Every .ella file must begin with a module declaration. It is the first
thing in the file — only comments may appear before it, never a const, enum,
model, service, or error.
module billing
const Currency = "USD"
model Invoice {
Id: string
Total: int64
}
A module is a namespace. Files that share the same module name are compiled together as one namespace, so a model, enum, const, or service can be split across several files. Declarations in different modules are fully isolated: the same name may be reused across modules without conflict, and a type reference resolves only within its own module.
# users.ella
module users
model User { Id: string }
# orders.ella
module orders
# This "User" does not collide with the one in module `users`.
model User { OrderId: string }
This is what keeps tooling (and the language server in particular) from reporting false "duplicate declaration" errors when two unrelated projects in the same workspace happen to use the same names — give them different module names and they stay independent.
The schema fragments in the sections below omit the
moduleline for brevity, but a real file always starts with one.
Constants define fixed values. They're useful for event topic names, configuration thresholds, or anything you want shared across generated code.
const TopicUserCreated = "app.user.created"
const TopicUserDeleted = "app.user.deleted"
const MaxUploadSize = 100mb
const RequestTimeout = 30s
Size units: kb, mb, gb, tb, eb
Time units: ms, s, m, h
Enums default to integer values starting at 0. You can also give them explicit string values.
# Integer enum (values: 0, 1, 2)
enum UserStatus {
Pending
Active
Disabled
}
# String enum
enum DeviceStatus {
Init = "init"
Online = "online"
Offline = "offline"
}
Models define data structures. Fields have a name and a type, separated by a colon.
model User {
Id: string
Email: string
Name: string
Status: UserStatus
Created: timestamp
Attributes: map<string, any>
}
Models can extend other models to reuse fields:
model Device {
...User
MachineId: string
DeviceStatus: DeviceStatus
}
| Type | Description |
|---|---|
string |
Text |
bool |
Boolean |
byte |
Single byte |
int8, int16, int32, int64 |
Signed integers |
uint8, uint16, uint32, uint64 |
Unsigned integers |
float32, float64 |
Floating point |
timestamp |
Unix timestamp |
any |
Untyped (maps to interface{} / any) |
[]Type |
Array of Type |
map<K, V> |
Map with key type K and value type V |
String constants with {{ }} placeholders generate functions instead of plain values:
const TopicUserStatus = "app.user.{{userId}}.status"
This generates a function that takes userId as a parameter and returns the interpolated string.
Services define RPC methods. Each method lists its request parameters and response fields.
service UserService {
Create (email: string, name: string) => (user: User)
GetById (id: string) => (user: User)
UpdateStatus (id: string, status: UserStatus) => (user: User)
Delete (id: string)
List () => (users: []User)
}
Methods without a return clause produce no response body.
Method arguments can be marked optional with ?, using the same syntax as optional model fields. Optional arguments must come after all required arguments:
service UserService {
List (query: string, limit?: int64, tags?: []string) => (users: []User)
}
service GroupService {
List (limit?: int64) => (groups: []Group)
}
In Go, optional arguments become type-safe functional options. A single With<Name> function is generated per unique argument name and is shared by every method that declares an optional argument with that name — WithLimit below works for both UserService.List and GroupService.List:
users, err := userClient.List(ctx, "active", WithLimit(50), WithTags([]string{"admin"}))
groups, err := groupClient.List(ctx, WithLimit(10))Because of this sharing, optional arguments with the same name must use the same type everywhere; the compiler reports an error otherwise.
Server implementations receive the same variadic options and read them through the generated per-method options struct, where unset arguments are nil:
func (s *server) List(ctx context.Context, query string, opts ...UserServiceListOption) ([]*User, error) {
options := NewUserServiceListOptions(opts...)
if options.Limit != nil {
// limit was provided
}
...
}In TypeScript (both .d.ts and .ts outputs), optional arguments are grouped into an optional object parameter placed between the required arguments and the call options:
const users = await userService.list("active", { limit: 50, tags: ["admin"] })
const groups = await groupService.list({ limit: 10 })On the wire, unset optional arguments are omitted from the JSON-RPC params entirely, so Go and TypeScript clients and servers are fully interoperable.
Named errors with optional HTTP status codes:
error ErrUserNotFound { Msg = "user not found" }
error ErrEmailConflict { Code = 409 Msg = "email already exists" }
These generate typed error values in Go that work with errors.Is().
The Go output includes:
- Struct types with
json:"camelCase"tags for all models - Enum types with
String(),MarshalJSON(), andUnmarshalJSON()methods - A service interface (e.g.
UserServiceHandler) withcontext.Contexton every method - A server constructor that wires up JSON-RPC method routing
- A client constructor that implements the same interface via JSON-RPC calls
- Typed error variables
For .d.ts output:
- Interface definitions for all models
- Enum types as string union types
- Service interfaces with
Promise<T>return types - Support for
AbortSignal, caching, and timeout options
For .ts output:
createFetchJsonRpc(host, options)helper compatible withella.to/jsonrpcrequest/response formatcreate<Service>(conn)factory functions that return async service clients- Runtime constants and enum values
EllaRPCErrorplus typed error guards for schema-defined errors
Example runtime client usage:
import {
createFetchJsonRpc,
createUserService,
isErrUserNotFound,
} from "./schema"
const conn = createFetchJsonRpc("https://api.example.com/rpc")
const users = createUserService(conn)
try {
const user = await users.getById("123")
console.log(user)
} catch (err) {
if (isErrUserNotFound(err)) {
console.error("user not found")
} else {
throw err
}
}The WASM output (with --allow-ext) generates Go code that:
- Creates a JavaScript-callable API object
- Wraps each service method as an async function
- Handles request/response serialization through the WASM bridge
- Supports client-side caching with configurable TTL
ella fmt normalizes your schema files by sorting declarations in a consistent order: constants, then enums, then models, then services, then errors. This keeps things tidy across a team.
ella fmt "./schema/src/*.ella"Ella ships a built-in language server. Start it with:
ella lspIt speaks the Language Server Protocol over stdio and provides:
| Feature | LSP method |
|---|---|
| Diagnostics (errors) | textDocument/publishDiagnostics |
| Go-to-definition | textDocument/definition |
| Hover | textDocument/hover |
| Document outline/symbols | textDocument/documentSymbol |
| Completion | textDocument/completion |
| Find references | textDocument/references |
| Formatting | textDocument/formatting |
Diagnostics combine scanner/parser errors with full schema validation, and the
server is workspace-aware: it indexes every .ella file under the workspace
root, so go-to-definition and "unknown type" checks work across files exactly
like ella gen does. Resolution is module-scoped — symbols, references, and
duplicate-name checks stay within a module, so two projects that share names but
declare different modules never interfere with each other.
The server has no runtime configuration — point your editor's LSP client at the
ella lsp command for documents with language id ella (file extension
.ella). It writes only protocol frames to stdout; logs go to stderr (or to a
file with ella lsp --log /path/to/ella-lsp.log). --stdio is accepted for
client compatibility but stdio is the only transport.
The extension in tools/syntax provides syntax highlighting and wires up
the language server automatically.
cd tools/syntax
code --install-extension ella-syntax-0.1.0.vsix --forceRun Developer: Reload Window afterward. The extension launches ella lsp for
any .ella file; ensure ella is on your PATH or set ella.server.path in
your settings. See tools/syntax/README.md for build
and configuration details.
vim.filetype.add({ extension = { ella = "ella" } })
vim.api.nvim_create_autocmd("FileType", {
pattern = "ella",
callback = function(args)
vim.lsp.start({
name = "ella-lsp",
cmd = { "ella", "lsp" },
root_dir = vim.fs.root(args.buf, { ".git", "go.mod" }) or vim.fn.getcwd(),
})
end,
})Add to ~/.config/helix/languages.toml:
[language-server.ella-lsp]
command = "ella"
args = ["lsp"]
[[language]]
name = "ella"
scope = "source.ella"
file-types = ["ella"]
comment-token = "#"
language-servers = ["ella-lsp"]
roots = [".git", "go.mod"]Any LSP-capable editor works: configure a server whose command is ella lsp,
associate it with the .ella extension / ella language id, and (optionally)
set the workspace root so cross-file features work. On initialize the server
reads workspaceFolders / rootUri and scans them for .ella files.
MIT — see LICENSE for details.