API Stability Notice
Macroforge is under active development. The API is not yet stable and may change between versions. Some documentation sections may be outdated.
Type-Aware Macros
Before expanding, Macroforge scans the project and records every class, interface, enum and type alias it finds. A macro can look any of them up, so it can generate code that depends on what a field's type really is, not just on its name.
The Type Registry
The context's type_registry holds the scan. Each entry is a type's IR, where it is
declared and the imports of its file:
pub struct TypeRegistryEntry {
pub name: String, // "User"
pub file_path: String, // where it is declared
pub is_exported: bool,
pub definition: TypeDefinitionIR, // Class, Interface, Enum or TypeAlias IR
pub file_imports: Vec<FileImportEntry>, // that file's imports
}Looking Types Up
| Method | Finds |
|---|---|
resolve_in_file(name, file, imports) | The type name means in file: what it imports under that name, or
what it declares. Use this one for a name read from a field. |
resolve(name, imports) | The same, without the same-file check |
get(name) | The type by name alone; None when several files declare that name |
get_all(name) | Every declaration with that name |
get_qualified("src/user.ts::User") | A declaration in a given file |
alias_cycle(name) | The loop, if following name's aliases leads back to it |
use macroforge_ts::ts_syn::TypeDefinitionIR;
let ctx = &input.context;
let imports = ctx.import_registry.file_import_entries();
let resolved_fields = ctx.resolved_fields.as_ref();
for field in class.fields() {
// The field's type with arrays and generics stripped: "User" for "User[]"
let Some(resolved) = resolved_fields.and_then(|fields| fields.get(&field.name)) else {
continue;
};
let Some(entry) =
ctx.type_registry.resolve_in_file(&resolved.base_type_name, &ctx.file_name, &imports)
else {
continue; // a primitive, a generic parameter, or a type outside the project
};
if let TypeDefinitionIR::Enum(enum_ir) = &entry.definition {
// the field holds one of enum_ir.variants
}
}Resolved Field Types
For a derive on a class or interface, resolved_fields has every field's type already
followed, keyed by field name:
pub struct ResolvedTypeRef {
pub raw_type: String, // "User[]"
pub base_type_name: String, // "User"
pub registry_key: Option<String>, // the registry entry, when it is a project type
pub is_collection: bool, // an array, Set, Map or the like
pub is_optional: bool, // | null or | undefined
pub type_args: Vec<ResolvedTypeRef>, // Map<string, User> → [string, User]
}use macroforge_ts::builtin::derive::common::resolved_type_has_derive;
if let Some(resolved) = input.context.resolved_fields.as_ref().and_then(|f| f.get(&field.name)) {
// Does the field's type, or its element type, also derive Clone?
if resolved_type_has_derive(&input.context.type_registry, resolved, "Clone") {
// call the type's generated clone helper instead of copying
}
}Generic Aliases
resolve_generic_aliases expands every generic alias in a type string, so a macro sees
the shape behind it. With type Link<T> = string | T, Link<User>[] becomes (string | User)[]:
use macroforge_ts::ts_syn::resolve_generic_aliases;
let shape = resolve_generic_aliases(
&field.ts_type,
&ctx.type_registry,
&ctx.file_name,
&ctx.import_registry.file_import_entries(),
);Helpers
macroforge_ts::builtin::derive::common holds the helpers the built-in macros use:
| Helper | Use |
|---|---|
type_has_derive(registry, name, "Clone") | Whether a project type derives a macro |
resolved_type_has_derive(registry, resolved, "Clone") | The same, for a field's resolved type |
collection_element_type(resolved) | The element of User[], a Set's value, a Map's value |
standalone_fn_name("User", "Clone") | The name of a generated helper: userClone |
get_effective_fields(alias, registry) | The fields of an object alias, or of an intersection of them |
is_primitive_type, is_numeric_type, is_nullable_type, is_generic_type | Classify a type string |
get_type_default, get_type_default_with_registry | A default value expression for a type |
Importing a Type's Helpers
Code generated for one type often calls helpers generated beside another, in another module. ctx.import_specifier_for("User") gives the module the current file should import from:
the one it already imports User from, or else the relative path to User's file. The stream methods in Output and Imports build on it.
The Project's Config
ctx.config is the project's macroforge.config, when there is one:
pub struct MacroforgeConfig {
pub keep_decorators: bool,
pub generate_convenience_const: bool,
pub foreign_types: Vec<ForeignTypeConfig>,
pub cfg: CfgFlags, // features, target, debug_assertions, custom
pub deprecated: DeprecatedConfig,
pub must_use: MustUseConfig,
pub non_exhaustive: NonExhaustiveConfig,
pub buildtime: BuildtimeConfig,
}Foreign types are the usual reason to read it:
a macro that handles field types itself should treat a configured foreign type, such as DateTime.DateTime, the way the project configured it.
let foreign = input
.context
.config
.as_ref()
.and_then(|config| config.foreign_types.iter().find(|ft| ft.name == field.ts_type));
if let Some(foreign) = foreign {
// Imports the handler from #macroforge/config and names it, so the
// generated code calls the project's own function.
if let Some(encode) = foreign.handler_callee(ForeignHandler::Encode) {
// emit `${encode}(value.field)`
}
}A handler runs in the project's config, not in your macro's output: handler_callee requests its import and returns the name to call. See Expanded Config for how the
config is expanded so each handler can be imported.