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

Add a codegen section to norq.config.yaml:

codegen:
  - lang: typescript
    out: ./src/generated/norq
  - lang: python
    out: ./generated/norq

Then run:

norq codegen

CLI one-off

Skip the config and pass flags directly:

norq codegen --lang typescript --out ./src/generated/norq

Both --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 WelcomeChannel

Go

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
end

Ruby 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_name becomes firstName)
  • Python: snake_case (kept as-is from schema)
  • Go: PascalCase fields with json:"original_name" tags
  • Java: camelCase (first_name becomes firstName)
  • 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 --check

This 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 --check

Pair with norq lint and norq test for a complete CI pipeline:

- run: norq lint --json
- run: norq test --json
- run: norq codegen --check

Config 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