id | title |
---|---|
emitters |
Emitters |
TypeSpec emitters are libraries that use various TypeSpec compiler APIs to reflect on the typespec compilation and produce generated artifacts. The typespec standard library includes an emitter for OpenAPI version 3.0, but odds are good you will want to emit TypeSpec to another output format. In fact, one of TypeSpec's main benefits is how easy it is to use TypeSpec as a source of truth for all data shapes, and the ease of writing an emitter is a big part of that story.
TypeSpec emitters are a special kind of TypeSpec library and so have the same getting started instructions.
Setup the boilerplate for an emitter using our template:
tsp init --template emitter-ts
or follow these steps to initialize a typespec library.
A TypeSpec emitter exports a function named $onEmit
from its main entrypoint. It receives two arguments:
- context: The current context including the current progfam being compiled
- options: Custom configuration options selected for this emitter
For example, the following will write a text file to the output directory:
import { EmitContext, emitFile, resolvePath } from "@typespec/compiler";
export async function $onEmit(context: EmitContext) {
if (!context.program.compilerOptions.noEmit) {
await emitFile(context.program, {
path: resolvePath(context.emitterOutputDir, "hello.txt"),
content: "Hello world\n",
});
}
}
You can now compile a TypeSpec program passing your library name to --emit, or add it to your tspconfig.yaml
.
To pass your emitter custom options, the options must be registered with the compiler by setting emitter.options
in your library definition to the JSON schema for the options you want to take. The compiler has a helper to make this easier:
- JSONSchemaType: Takes a TypeScript interface and returns a type that helps you fill in the JSON schema for that type.
The following example extends the hello world emitter to be configured with a name:
export const $lib = createTypeSpecLibrary({
name: "MyEmitter",
diagnostics: {
// Add diagnostics here.
},
state: {
// Add state keys here for decorators.
},
});
import {
JSONSchemaType,
createTypeSpecLibrary,
EmitContext,
resolvePath,
} from "@typespec/compiler";
import { internalLib } from "./lib.js";
export interface EmitterOptions {
"target-name": string;
}
const EmitterOptionsSchema: JSONSchemaType<EmitterOptions> = {
type: "object",
additionalProperties: false,
properties: {
"target-name": { type: "string", nullable: true },
},
required: [],
};
export const $lib = createTypeSpecLibrary({
internal: internalLib,
emitter: {
options: EmitterOptionsSchema,
},
});
export async function $onEmit(context: EmitContext<EmitterOptions>) {
const outputDir = resolvePath(context.emitterOutputDir, "hello.txt");
const name = context.options.targetName;
await context.program.host.writeFile(outputDir, `hello ${name}!`);
}
Specify that the value for this option should resolve to an absolute path. e.g. "{project-root}/dir"
.
:::important
It is recommended that all options that involve path use this. Using relative path can be confusing for users on as it is not clear what the relative path is relative to. And more importantly relative path if not careful are resolved relative to the cwd
in node file system which result in spec only compiling from the the project root.
:::
Example:
{
"asset-dir": { type: "string", format: "absolute-path", nullable: true },
}
- Name options
kebab-case
. So it can be inline with the rest of the cli - An option called
output-dir
can be created and should override the compileroutput-dir
The general guideline is to use a decorator when the customization is intrinsic to the API itself. In other words, when all uses of the TypeSpec program would use the same configuration. This is not the case for outputFilename
because different users of the API might want to emit the files in different locations depending on how their code generation pipeline is set up.
One of the main tasks of an emitter is finding types to emit. There are three main approaches:
- The emitter framework, which makes it relatively easy to emit all your TypeSpec types (or a subset, if you wish).
- The Semantic Walker, which lets you easily run code for every type in the program
- Custom traversal, which gives you a lot more flexibility than either of the previous approaches at the cost of some complexity.
The emitter framework provides handles a lot of hard problems for you while providing an easy-to-use API to convert your TypeSpec into source code or other object graphs. Visit the emitter framework page to learn more.
The Semantic Walker will visit every type in the TypeSpec program and call any callbacks you provide for that type. To use, import navigateProgram
from @typespec/compiler
. Starting a walk needs two parameters - the program to walk, and an object with callbacks for each type. For example, if we want to do something for every model in the program, we could do the following in our $onEmit
function:
navigateProgram(program, {
model(m) {
// emit m
},
});
You can provide a callback for every kind of TypeSpec type. The walker will call your callback pre-order, i.e. as soon as it sees a type for the first time it will invoke your callback. You can invoke callback post-order instead by prefixing the type name with exit
, for example exitModel(m)
.
Note that the semantic walker will visit all types in the program including built-in TypeSpec types and typespec types defined by any libraries you're using. Care must be taken to filter out any types you do not intend to emit. Sometimes this is quite difficult, so a custom traversal may be easier.
Often times you will want to emit specific types, for example types that have a particular decorator or are in a particular namespace. In such cases it is often easier to write a custom traversal to find and emit those types. Once you have a type, you can access its various fields to and emit those types as well if needed.
For example, let's say we want to emit a text file of model names but only if it has an @emitThis
decorator. We could filter out such models in the Semantic Walker model
callback, but it is more efficient to implement @emitThis
such that it keeps a list of all the types its attached to and iterate that list. We can then traverse into types it references if needed.
The following example will emit models with the @emitThis
decorator and also any models referenced by that model.
See creating decorator documentation for more details
import { DecoratorContext, Model } from "@typespec/compiler";
import { StateKeys } from "./lib.js";
// Decorator Setup Code
// @emitThis decorator
export function $emitThis(context: DecoratorContext, target: Model) {
context.program.stateSet(StateKeys.emitThis).add(target);
}
export async function $onEmit(context: EmitContext) {
for (const model of program.stateSet(emitThisKey)) {
emitModel(model);
}
}
function emitModel(model: Model) {
// emit this model
for (const prop of model.properties.values()) {
// recursively emit models referenced by the parent model
emitModel(prop.type);
}
}
Sometimes you might want to get access to a known TypeSpec type in the type graph, for example a model that you have defined in your library.
A helper is provided on the program to do that.
program.resolveTypeReference(reference: string): Type | undefined;
The reference must be a valid typespec reference(Like you would have it in a typespec document)
Example
program.resolveTypeReference("TypeSpec.string"); // Resolve typespec string intrinsic type
program.resolveTypeReference("MyOrg.MyLibrary.MyEnum"); // Resolve `MyEnum` defined in `MyOrg.MyLibrary` namespace.
Error example
program.resolveTypeReference("UnknownModel"); // Resolve `[undefined, diagnostics]` where diagnostics is an array of diagnostic explaining why reference is invalid.
program.resolveTypeReference("model Foo {}"); // Resolve `[undefined, diagnostics]` where diagnostics is an array of diagnostic explaining why reference is invalid.
Since an emitter is a node library, you could use standard fs
APIs to write files. However, this approach has a drawback - your emitter will not work in the browser, and will not work with the test framework that depends on storing emitted files in an in-memory file system.
Instead, use the compiler host
interface to access the file system. The API is equivalent to the node API but works in a wider range of scenarios.
In order to know where to emit files, the emitter context has a emitterOutputDir
property that is automatically resolved using the emitter-output-dir
built-in emitter options. This is set to {cwd}/tsp-output/{emitter-name}
by default, but can be overridden by the user. Do not use the compilerOptions.outputDir
Scalars are types in TypeSpec that most likely have a primitive or built-in datastructure representing those in the target language.
Recommended logic for emitting scalar is to:
- If scalar is a known scalar(e.g.
int32
), emit the known mapping. - Otherwise check scalar
baseScalar
and go back to1.
2.1 After resolving which scalar apply any decorators
:::note If the scalar is generic and doesn't have a mapping (e.g. integer), we recommend substituting it with the next closest mapping (e.g. integer->int64) and emitting a warning. :::
@minValue(10)
scalar myInt32 extends int32;
@minValue(20)
scalar specializedInt32 extends myInt32;
Scalar | Expected type | Description |
---|---|---|
int16 |
int16 |
Simple case, emitter can know it is an int16 |
myInt32 |
int32 |
Emitter doesn't know what myInt32 is. Check baseScalar, sees it is an int32, applies minValue decorator. |
specializedInt32 |
int32 |
Emitter doesn't know what specializedInt32 is. Check baseScalar, finds myInt32 knows that it is an int32 now and applies minValue override. |
float |
float64 |
Emitter knows float but doesn't have a mapping. Emit float64 and a warning. |
Several TypeSpec types have a default
property that can be used to specify a default value. For example, the following model has a default value of true
for the isActive
property:
model User {
isActive?: boolean = true;
}
These values can be accessed in the emitter using the default
property on the ModelProperty
type.
const modelProp: ModelProperty = ...; // the isActive ModelProperty type
const defaultValue = modelProp.default; // value: true
It is important that emitters handle default values in a consistent way. Default values SHOULD NOT be used as client-side default values. Instead, they should be used as a way to specify a default value for the server-side implementation. For example, if a model property has a default value of true
, the server-side implementation should use that value if the client does not provide a value. Default values SHOULD be expressed in documentation to properly communicate the service-side default.