Code Generation
Norq generates type-safe SDK bindings from your data.schema.yaml files. This is the bridge between your templates and your application code — it turns your schema into real types that your compiler and IDE can enforce.
Why this matters:
Without codegen, sending a notification looks like this:
// No type safety — anything goes, errors surface in production
await norq.send("transactional/order-confirmation", {
to: { email: "alice@example.com" },
data: { custmer: { name: "Alice" }, order: { id: 123 } },
// ^^^^^^ typo — silently passes, template renders blank
// ^^^^^ wrong type — should be string
});With codegen:
import type { OrderConfirmationData } from "./generated/norq/transactional/order_confirmation";
const data: OrderConfirmationData = {
custmer: { name: "Alice" },
// ^^^^^ TS error: 'custmer' does not exist. Did you mean 'customer'?
order: { id: 123 },
// ^^^ TS error: Type 'number' is not assignable to type 'string'
};Every misspelled field, wrong type, missing required field, and invalid channel is caught before your code runs. This works in TypeScript, Python (with mypy/pyright), Go, Java, and Ruby (Sorbet).
Quick start
Config-driven (recommended)
Add a codegen section to norq.config.yaml:
codegen:
- lang: typescript
out: ./src/generated/norq
- lang: python
out: ./generated/norqThen run:
norq codegenCLI one-off
Skip the config and pass flags directly:
norq codegen --lang typescript --out ./src/generated/norqBoth --lang and --out are required when using flags. If either is missing, the command falls back to the config file.
Supported languages
| Flag value | Alias | Output |
|---|---|---|
typescript |
ts |
.ts files with interfaces |
python |
py |
.py files with TypedDict classes |
go |
-- | .go files with structs + json tags |
java |
-- | .java files with records |
ruby |
rb |
.rb files with Sorbet T::Struct classes |
Output structure
Generated files mirror your notification directory hierarchy. For a project with two notifications:
notifications/
transactional/
welcome/
data.schema.yaml
email.md
sms.md
account/
security-alert/
data.schema.yaml
email.md
TypeScript output:
src/generated/norq/
transactional/
welcome.ts
account/
security-alert.ts
index.ts
Python output:
generated/norq/
transactional/
welcome.py
account/
security_alert.py
__init__.py
Go output:
generated/norq/
transactional/
welcome.go
account/
security-alert.go
Java output:
generated/norq/
transactional/
Welcome.java
account/
SecurityAlert.java
NorqTyped.java
Ruby output:
generated/norq/
transactional/
welcome.rb
account/
security-alert.rb
Every generated file starts with a DO NOT EDIT header.
Per-language examples
Given this data.schema.yaml for transactional/welcome:
type: object
required: [user, action_url]
properties:
user:
type: object
required: [first_name, email]
properties:
first_name: { type: string }
last_name: { type: string }
email: { type: string, format: email }
tier: { type: string, enum: [free, pro, enterprise] }
action_url: { type: string, format: uri }
app_name: { type: string }TypeScript
File: transactional/welcome.ts
// Generated by norq codegen — DO NOT EDIT
interface _TransactionalWelcomeDataUser {
email: string;
firstName: string;
lastName?: string;
tier?: "free" | "pro" | "enterprise";
}
export interface WelcomeData {
actionUrl: string;
appName?: string;
user: _TransactionalWelcomeDataUser;
}
export type WelcomeChannel = "email" | "sms";File: index.ts
// Generated by norq codegen — DO NOT EDIT
import { WelcomeData } from "./transactional/welcome";
import { WelcomeChannel } from "./transactional/welcome";
export interface NorqNotifications {
"transactional/welcome": { data: WelcomeData; channels: WelcomeChannel };
}The NorqNotifications interface maps notification IDs to their data and channel types -- use it to type your send wrapper.
Python
File: transactional/welcome.py
# Generated by norq codegen — DO NOT EDIT
from __future__ import annotations
from typing import Literal, NotRequired, TypedDict
class _TransactionalWelcomeDataUser(TypedDict):
email: str
first_name: str
last_name: NotRequired[str]
tier: NotRequired[Literal["free", "pro", "enterprise"]]
class WelcomeData(TypedDict):
action_url: str
app_name: NotRequired[str]
user: _TransactionalWelcomeDataUser
WelcomeChannel = Literal["email", "sms"]File: __init__.py
# Generated by norq codegen — DO NOT EDIT
from .transactional.welcome import WelcomeData
from .transactional.welcome import WelcomeChannelGo
File: transactional/welcome.go
// Generated by norq codegen — DO NOT EDIT
package norq
type transactionalWelcomeDataUser struct {
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName *string `json:"last_name,omitempty"`
Tier *string `json:"tier,omitempty"`
}
type WelcomeData struct {
ActionUrl string `json:"action_url"`
AppName *string `json:"app_name,omitempty"`
User transactionalWelcomeDataUser `json:"user"`
}
func SendWelcome(c *Client, ctx context.Context, to Recipient, data WelcomeData, channels ...string) (*SendMultiResult, error) {
return c.Send(ctx, "transactional/welcome", SendOpts{To: to, Data: structToMap(data), Channels: channels})
}Go generates a typed SendWelcome wrapper function alongside each struct.
Java
File: transactional/Welcome.java
// Generated by norq codegen — DO NOT EDIT
package com.example.norq;
import org.jspecify.annotations.Nullable;
import java.util.List;
record TransactionalWelcomeDataUser(
String email,
String firstName,
@Nullable String lastName,
@Nullable String tier
) {}
public record WelcomeData(
String actionUrl,
@Nullable String appName,
TransactionalWelcomeDataUser user
) {}File: NorqTyped.java
// Generated by norq codegen — DO NOT EDIT
package com.example.norq;
public class NorqTyped {
private final Norq client;
public NorqTyped(Norq client) { this.client = client; }
public SendMultiResult sendWelcome(Recipient to, WelcomeData data, String... channels) {
return client.send("transactional/welcome", to, data, channels);
}
}Java uses records for data types and a NorqTyped wrapper class with typed send methods.
Ruby
File: transactional/welcome.rb
# Generated by norq codegen — DO NOT EDIT
# typed: strict
class _TransactionalWelcomeDataUser < T::Struct
const :email, String
const :first_name, String
const :last_name, T.nilable(String)
const :tier, T.nilable(String)
end
class WelcomeData < T::Struct
const :action_url, String
const :app_name, T.nilable(String)
const :user, _TransactionalWelcomeDataUser
endRuby generates Sorbet-typed T::Struct classes. Files include the # typed: strict sigil.
Type mapping
| JSON Schema | TypeScript | Python | Go | Java | Ruby |
|---|---|---|---|---|---|
string |
string |
str |
string |
String |
String |
string + enum |
"a" | "b" |
Literal["a", "b"] |
string |
String |
String |
number |
number |
float |
float64 |
Double |
Float |
integer |
number |
int |
int64 |
Long |
Integer |
boolean |
boolean |
bool |
bool |
Boolean |
T::Boolean |
array |
T[] |
list[T] |
[]T |
List<T> |
T::Array[T] |
| nullable | T | null |
T | None |
*T |
@Nullable T |
T.nilable(T) |
| optional | T | undefined |
NotRequired[T] |
*T |
@Nullable T |
T.nilable(T) |
object |
interface |
TypedDict |
struct |
record |
T::Struct |
Nullable is detected from oneOf/anyOf with a null variant, or the x-nullable extension. Optional is inferred when a field is not in the required array.
Naming conventions
Type names use the leaf segment
The notification ID transactional/account/security-alert generates a type named SecurityAlertData, not TransactionalAccountSecurityAlertData. Only the last path segment (the notification name) is used for the public type name, converted to PascalCase.
Nested types are private
Nested object types retain their fully-qualified name (built from the full notification ID path + field name) to avoid collisions, and are marked private in each language:
| Language | Top-level | Nested |
|---|---|---|
| TypeScript | export interface WelcomeData |
interface _TransactionalWelcomeDataUser (no export, _ prefix) |
| Python | class WelcomeData(TypedDict) |
class _TransactionalWelcomeDataUser(TypedDict) (_ prefix) |
| Go | type WelcomeData struct (exported) |
type transactionalWelcomeDataUser struct (unexported, lowercase first) |
| Java | public record WelcomeData(...) |
record TransactionalWelcomeDataUser(...) (package-private, no public) |
| Ruby | class WelcomeData < T::Struct |
class _TransactionalWelcomeDataUser < T::Struct (_ prefix) |
Field casing is language-idiomatic
- TypeScript:
camelCase(first_namebecomesfirstName) - Python:
snake_case(kept as-is from schema) - Go:
PascalCasefields withjson:"original_name"tags - Java:
camelCase(first_namebecomesfirstName) - Ruby:
snake_case(kept as-is from schema)
The type-safety workflow
The full loop from template change to type-safe application code:
1. Edit data.schema.yaml → add/change/remove fields
2. norq lint → validates templates against schema
3. norq codegen → regenerates typed bindings
4. Compile your app → type errors surface for any send() call
that passes the wrong shape
This means a schema change — adding a required field, renaming a property, changing a type from string to number — propagates all the way through to your application code. If your send() call doesn't match the new schema, you find out at compile time, not when a customer gets a broken notification.
For teams, commit the generated files. When someone changes a schema and runs norq codegen, the diff in the generated types makes the impact visible in code review.
CI integration
Use --check to verify generated files are up to date without writing anything:
norq codegen --checkThis compares each generated file against the existing output directory. If any file differs, the command prints a diff summary and exits with code 1.
# GitHub Actions
- run: norq codegen --checkPair with norq lint and norq test for a complete CI pipeline:
- run: norq lint --json
- run: norq test --json
- run: norq codegen --checkConfig reference
The codegen section in norq.config.yaml is an array of targets. Each target has two required fields:
| Field | Type | Description |
|---|---|---|
lang |
string | Target language: typescript, python, go, java, ruby (or aliases ts, py, rb) |
out |
string | Output directory (relative to config file, or absolute) |
Multiple targets generate bindings for several languages in one norq codegen run:
codegen:
- lang: typescript
out: ./src/generated/norq
- lang: python
out: ./python_app/generated/norq
- lang: go
out: ./internal/norq