id | title |
---|---|
emitter-framework |
Emitter framework |
The emitter framework makes writing emitters from TypeSpec to other assets a fair bit easier than manually consuming the type graph. The framework gives you an easy way to handle all the types TypeSpec might throw at you and know when you're "feature complete". It also handles a lot of hard problems for you, such as how to construct references between types, how to handle circular references, or how to propagate the context of the types you're emitting based on their containers or where they're referenced from. Lastly, it provides a class-based inheritance model that makes it fairly painless to extend and customize existing emitters.
Make sure to read the getting started section under the emitter basics topic. To use the framework, you will need an emitter library and $onEmit
function ready to go.
Implementing an emitter using the emitter framework will use a variety of types from the framework. To give you a high level overview, these are:
AssetEmitter
: The asset emitter is the main type you will interact with in your$onEmit
function. You can pass the asset emitter types to emit, and tell it to write types to disk or give you source files for you to process in other ways.TypeEmitter
: The type emitter is the base class for most of your emit logic. Every TypeSpec type has a corresponding method on TypeEmitter. It also is where you will manage your emit context, making it easy to answer such questions as "is this type inside something I care about" or "was this type referenced from something".CodeTypeEmitter
: A subclass ofTypeEmitter
that makes building source code easier.StringBuilder
,ObjectBuilder
,ArrayBuilder
: when implementing yourTypeEmitter
you will likely use these classes to help you build strings and object graphs. These classes take care of handling the placeholders that result from circular references.
Let's walk through each of these types in turn.
The asset emitter is responsible for driving the emit process. It has methods for taking TypeSpec types to emit, and maintains the state of your current emit process including the declarations you've accumulated, current emit context, and converting your emitted content into files on disk.
To create your asset emitter, call createAssetEmitter
on your emit context in $onEmit
. It takes the TypeEmitter which is covered in the next section. Once created, you can call emitProgram()
to emit every type in the TypeSpec graph. Otherwise, you can call emitType(someType)
to emit specific types instead.
export async function $onEmit(context: EmitContext) {
const assetEmitter = context.createAssetEmitter(MyTypeEmitter);
// emit my entire typespec program
assetEmitter.emitProgram();
// or, maybe emit types just in a specific namespace
const ns = context.program.resolveTypeReference("MyNamespace")!;
assetEmitter.emitType(ns);
// lastly, write your emit output into the output directory
await assetEmitter.writeOutput();
}
This is the base class for writing logic to convert TypeSpec types into assets in your target language. Every TypeSpec type has at least one method on this base class, and many have multiple methods. For example, models have both ModelDeclaration
and ModelLiteral
methods to handle model Pet { }
declarations and { anonymous: boolean }
literals respectively.
To support emitting all TypeSpec types, you should expect to implement all of these methods. But if you don't want to support emitting all TypeSpec types, you can either throw or just not implement the method, in which case the type will not be emitted.
The generic type parameter T
is the type of emit output you are building. For example, if you're emitting source code, T
will be string
. If you're building an object graph like JSON, T
will be object
. If your T
is string
, i.e. you are building source code, you will probably want to use the CodeTypeEmitter
subclass which is a bit more convenient, but TypeEmitter<string>
will also work fine.
A simple emitter that doesn't do much yet might look like:
class MyCodeEmitter extends CodeTypeEmitter {
modelDeclaration(model: Model, name: string) {
console.log("Emitting a model named", name);
}
}
Passing this to createAssetEmitter
and calling assetEmitter.emitProgram()
will console.log all the models in the program.
Most methods of the TypeEmitter
must either return T
or an EmitterOutput<T>
. There are four kinds of EmitterOutput
:
Declaration<T>
: A declaration, which has a name and is declared in a scope, and so can be referenced by other emitted types (more on References later). Declarations are created by callingthis.emitter.result.declaration(name, value)
in your emitter methods. Scopes come from your current context, which is covered later in this document.RawCode<T>
: Output that is in some way concatenated into the output but cannot be referenced (e.g. things like type literals). Raw code is created by callingthis.emitter.result.rawCode(value)
in your emitter methods.NoEmit
: The type does not contribute any output. This is created by callingthis.emitter.result.none()
in your emitter methods.CircularEmit
: Indicates that a circular reference was encountered, which is generally handled by the framework with Placeholders (see the next section). You do not need to create this result yourself, the framework will produce this when required.
When an emitter returns T
or a Placeholder<T>
, it behaves as if it returned RawCode<T>
with that value.
To create these results, you use the result.*()
APIs on AssetEmitter
, which can be accessed via this.emitter.result
in your methods.
With this in mind, we can make MyCodeEmitter
a bit more functional:
class MyCodeEmitter extends CodeTypeEmitter {
// context and scope are covered later in this document
programContext(program: Program): Context {
const sourceFile = this.emitter.createSourceFile("test.txt");
return {
scope: sourceFile.globalScope,
};
}
modelDeclaration(model: Model, name: string) {
const props = this.emitter.emitModelProperties(model);
return this.emitter.result.declaration(name, `declaration of ${name}`);
}
}
If we have a typespec program that looks like:
model Pet {}
and we call assetEmitter.writeOutput()
, we'll find test.txt
contains the contents "declaration of Pet"
.
In order to emit properties of Pet
, we'll need to concatenate the properties of pets with the declaration we made and leverage builders to make that easy. These topics are covered in the next two sections.
It is very rare that you only want to emit a declaration and nothing else. Probably your declaration will have various parts to it, and those parts will depend on the emit output of the parts of the type your emitting. For example, a declaration from a TypeSpec model will likely include members based on the members declared in the TypeSpec.
This is accomplished by calling emit
or other emit*
methods on the asset emitter from inside your AssetEmitter
methods. For example, to emit the properties of a model declaration, we can call this.emitter.emitModelProperties(model)
. This will invoke your the corresponding AssetEmitter
method and return you the EmitterOutput
result.
It is unlikely that you want to concatenate this result directly. For declarations and raw code, the value
property is likely what you're interested in, but there are other complexities as well. So in order to concatenate results together, you probably want to use a builder.
Builders are helpers that make it easy to concatenate output into your final emitted asset. They do two things of note: they handle extracting the value from Declaration
and rawCode
output, and they handle Placeholder
values that crop up due to circular references. Three builders
are provided:
- Strings: Using the
code
template literal tag, you can concatenateEmitterOutput
s together into a final string. - Object: Constructing an
ObjectBuilder
with an object will replace anyEmitterOutput
in the object with its value and handle placeholders as necessary. - Array: Constructing an
ArrayBuilder
will let you pushEmitterOutput
and pull out the value and placeholders as necessary.
Now with these tools, we can make MyCodeEmitter
even more functional:
class MyCodeEmitter extends CodeTypeEmitter {
// context is covered later in this document
programContext(program: Program): Context {
const sourceFile = this.emitter.createSourceFile("test.txt");
return {
scope: sourceFile.globalScope,
};
}
modelDeclaration(model: Model, name: string) {
const props = this.emitter.emitModelProperties(model);
return this.emitter.result.declaration(name, code`declaration of ${name} with ${props}`);
}
modelPropertyLiteral(property: ModelProperty): EmitterOutput<string> {
return code`a property named ${property.name} and a type of ${this.emitter.emitType(
property.type
)}`;
}
modelLiteral(model: Model) {
return `an object literal`;
}
}
Now given a typespec program like:
model Pet {
position: {};
}
we will find test.txt
contains the output
declaration of Pet with a property named position and a type of an object literal
A common scenario when emitting to most targets is handling how to make references between types. This can get pretty complex, especially when the declarations are emitted into different scopes. The emitter framework does a lot of heavy lifting for you by calculating the scopes between your current context and the declaration you're trying to reference.
How declarations arrive in different scopes is covered in the Context section later in this document.
Let's look at the reference
signature on the TypeEmitter:
reference(
targetDeclaration: Declaration<string>,
pathUp: Scope<string>[],
pathDown: Scope<string>[],
commonScope: Scope<string> | null
): string | EmitEntity<string> {}
The reference
function is called with:
targetDeclaration
: The declaration we're making a reference to.pathUp
: The scopes between our current scope and the common scope.pathDown
: The scopes between the common scope and the declaration we're referencing.commonScope
: The nearest scope shared between our current scope and the target declaration.
So let's imagine we have declarations under the following scopes:
source file
namespace A
namespace B
model M1
namespace C
model M2
If M1 references M2, reference
will be called with the following arguments:
targetDeclaration
: M2pathUp
: [namespace B, namespace A]pathDown
: [namespace C]commonScope
: source file
For languages which walk up a scope chain in order to find a reference (e.g. TypeScript, C#, Java, etc.), you generally won't need pathUp
, you can just join the scopes in the pathDown
resulting in a reference like C.M2
. Other times you may need to construct a more path-like reference, in which case you can emit for example a ../
for every item in pathUp
, resulting in a reference like ../../C/M2
.
When the declarations don't share any scope, commonScope
will be null. This happens when the types are contained in different source files. In such cases, your emitter will likely need to import the target declaration's source file in addition to constructing a reference. The source file has an imports
property that can hold a list of such imports.
We can update our example emitter to generate references by adding an appropriate references
method:
class MyCodeEmitter extends CodeTypeEmitter {
// snip out the methods we implemented previously
// If the model is Person, put it into a special namespace.
// We will return to this in detail in the next section.
modelDeclarationContext(model: Model, name: string): Context {
if (model.name === "Person") {
const parentScope = this.emitter.getContext().scope;
const scope = this.emitter.createScope({}, "Namespace", parentScope);
return {
scope,
};
} else {
return {};
}
}
reference(
targetDeclaration: Declaration<string>,
pathUp: Scope<string>[],
pathDown: Scope<string>[],
commonScope: Scope<string> | null
): string | EmitEntity<string> {
const segments = pathDown.map((s) => s.name);
segments.push(targetDeclaration.name);
return `a reference to ${segments.join(".")}`;
}
}
Now if we emit the following TypeSpec program:
model Pet {
person: Person;
}
model Person {
pet: Pet;
}
We will find that test.txt
contains the following text:
declaration of Pet with a property named person and a type of a reference to Namespace.Person
Consider the following TypeSpec program:
model Pet {
owner: Person;
}
model Person {
pet: Pet;
}
In order to emit Pet
, we need to emit Person
, so we go to emit that. But in order to emit Person
, we need to emit Pet
, which is what we're already trying to do! We're at an impasse. This is a circular reference.
The emitter framework handles circular references via Placeholder
s. When a circular reference is encountered, the value
of an EmitterOutput
is set to a placeholder that is filled in when we've finished constructing the thing we referenced. So in the case above, when emitting Person
and we come across the circular reference to Pet
, we'll return a Placeholder
. We'll then come back to Pet
, finish it and return an EmitterOutput
for it, and then set any Placeholder
s waiting for Pet
to that output.
If you're using the Builder
s that come with the framework, you will not need to worry about dealing with Placeholder
yourself.
A common need when emitting TypeSpec is to know what context you are emitting the type in. There is one piece of required context: scope
, which tells the emitter framework where you want to place your declarations. But you might also want to easily answer questions like: am I emitting a model inside a particular namespace? Or am I emitting a model that is referenced from the return type of an operation? The emitter framework makes managing this context fairly trivial.
Every method that results in an EmitterOutput
has a corresponding method for setting lexical and reference context. We saw this above when we created modelDeclarationContext
in order to put some models into a different namespace.
Lexical context is available when emitting types that are lexically contained within the emitted entity in the source TypeSpec. For example, if we set modelDeclarationContext
, that context will be visible when emitting the model's properties and any nested model literals.
Reference context is passed along when making references and otherwise propagates lexically. For example, if we set modelDeclarationReferenceContext
, that context will be visible when emitting the model's properties and any nested model literals just like with lexical context. But unlike with lexical context, if the current model references another type, then the reference context will be visible when emitting the referenced model.
Note that this means that we may emit the same model multiple times. Consider the following TypeSpec program:
model Pet {}
model Person {
pet: Pet;
}
If, when emitting Person, we set the reference context to { refByPerson: true }
, we will call emitModel
for Pet
twice, once with no context set, and once again with the context we set when emitting Person
. This behavior is very handy when you want to emit the same model different ways depending on how it is used, e.g. when your emit differs whether a model is an input type or output type, or when a model's properties differ based on any @visibility
decorators and the context the model appears in (e.g. for Resources, whether it's being read, updated, created, deleted, etc.).
The scope that declarations are created in is set in using context. When emitting all of your TypeSpec program into the same file, and not emitting types into any kind of namespace, it suffices to set scope once in programContext
. Call this.emitter.createSourceFile("filePath.txt")
to create a source file, which comes with a scope ready to use.
To emit into different source files, e.g. if we want to emit using a "one class per file" pattern, move the into a more granular context method. For example, if we instead create source files in modelDeclarationContext
, then declarations for each model will be in their own file.
If we want to emit into namespaces under a source file, we can create scopes manually. Call this.emitter.createScope(objectReference, name, parentScope)
. The objectReference
is an object with metadata about the scope. You might use this to emit e.g. a namespace declaration in your target language, but often it can just be an empty object ({}
). Name is the name of the scope, used when constructing references. And parent scope is the scope this is found under.
Lets return to our previous example:
modelDeclarationContext(model: Model, name: string): Context {
if (model.name === "Person") {
const parentScope = this.emitter.getContext().scope;
const scope = this.emitter.createScope({}, "Namespace", parentScope);
return {
scope,
};
} else {
return {};
}
}
We can now see how this results in the Person
model being located in a nested scope - because we set scope
on the context to a new scope we created via this.emitter.setScope
.
TypeEmitters are classes and explicitly support subclassing, so you can customize an existing emitter by extending it and overriding any methods you want to customize in your subclass. In fact, emitters you find out in the ecosystem are likely not to work without creating a subclass, because they only know how to emit types, but you need to provide the scope for any declarations it wants to create. For example, if we have a base TypeScriptEmitter
that can convert TypeSpec into TypeScript, we might extend it to tell it to put all declarations in the same file:
class MyTsEmitter extends TypeScriptEmitter {
programContext(program: Program): Context {
const sourceFile = this.emitter.createSourceFile("test.txt");
return {
scope: sourceFile.globalScope,
};
}
}
Or, if we want one class or interface per file, we might instead do something like:
class MyTsEmitter extends TypeScriptEmitter {
modelDeclarationContext(program: Program): Context {
const sourceFile = this.emitter.createSourceFile("test.txt");
return {
scope: sourceFile.globalScope,
};
}
// and similar for other declarations: Unions, Enums, Interfaces, and Operations.
}