This feature introduces a new kind of declaration to TypeSpec:
'extern' 'fn' Identifier '(' Parameter* ')' (':' TypeRef)? ';'An extern fn declares a binding to a JavaScript function, similar to how extern dec binds decorators. The key difference is that extern fn declarations return a value or type directly, rather than applying to a target declaration.
- Arguments are resolved and constrained using the same rules as decorator arguments.
- Return types are constrained in reverse: we match the JS result to the return type constraint to determine if it is a Type or a Value.
- If no return type is specified, the function returns a
Typeimplicitly constrained tounknown.
extern fn applyFilter(type: Reflection.Type, filter: valueof Filter): Reflection.Type;
alias Filtered<T, Filter extends valueof Filter> = applyFilter(T, Filter);Previously, the recommended way to implement transformation logic that returns a modified type was to use a mutating decorator on an empty template:
@withFiltered(T, Filter)
model Filtered<T extends Reflection.Model, Filter extends valueof Filter> {}This pattern has several issues:
- The result type is always of the declared kind (e.g.
model) and cannot produce other kinds like unions or enums. - Decorators applied to the input type (
T) do not propagate to the result unless handled manually. - The mutation API requires copying information from the constructed result back onto the original template instance.
By contrast, functions:
- Can return types of arbitrary kind
- Are pure (return a new entity rather than mutating an existing one)
- Preserve decorators by operating directly on the transformed output
A customer reported an issue implementing a "List" visibility modifier (for properties that should not be returned when listing a resource, because they are expensive to compute). This is not supported by lifecycle visibility currently, but can be implemented with a custom visibility class as a workaround. Through discussing this with the customer, we found that our existing visibility template system does not preserve approrpiate decorator metadata for transformed models, but only at the root of the transform.
Investigation revealed:
- Decorators on nested types in the transform are preserved (as handled by the mutator API)
- The top-level input type's decorators were lost, since they were not explicitly copied onto the new model instance returned from the template
This defect is a direct consequence of the mutating template model. By using a function instead, the decorator remains attached to the input through mutators.
- Visibility transforms (
Create,Read,Update,Delete,Query) - Merge-Patch transforms (
MergePatchUpdateand friends)
'extern' 'fn' Identifier '(' Parameter* ')' (':' TypeRef)? ';'-
Parameters are resolved using the same logic as decorators.
-
Return type constraints work in reverse: the value returned from JS is resolved and then checked against the declared constraint.
- If the return type is
Type | valueof string, we will check if the JS result looks like a TypeSpec Type, otherwise we will attempt to unmarshal it to a Value.
- If the return type is
-
If omitted, the return type is implicitly
unknown, constrained to the Type side only.- In this case, a returned Value will cause an error.
- If you want to return either a type or value, you must write:
unknown | valueof unknown(it is possible to change this default behavior and allow either a type or value by default).
// No arguments, must return a type
extern fn makeSomething();
// Argument must be a type assignable to string, returns a type
extern fn makeSomething(s: string);
// Argument must be a string value, returns a type
extern fn makeSomething(s: valueof string);
// No arguments, returns a value
extern fn defaultFilter(): valueof unknown;
// Accepts a `Filter` value, and also returns one.
extern fn strengthenFilter(f: valueof Filter): valueof Filter;
// Accepts either a type instance of kind `EnumMember` or a string Value. Returns any Value.
extern fn reflectOrLiteral(v: Reflection.EnumMember | valueof string): valueof unknown;These declarations correspond to functions exported in the JavaScript FFI surface under $functions:
export const $functions = {
"My.Namespace": {
makeSomething(program: Program): Type {
// Do anything you like here, just return a Type.
},
defaultFilter(program: Program): unknown {
// Do anything you like here, just return something that can unmarshal to a Value
},
strengthenFilter(program: Program, f: Filter): Filter {
// Compute the new filter from `f` and return it
}
} satisfies MyNamespaceFunctions;
}-
Functions are invoked during
CallExpressionresolution when the callee is aFunctionType. -
extern fndeclarations bind to their parent namespace and appear underfunctionDeclarationsin the Namespace type graph. -
Function declarations are reachable in NavigateType.
-
Arguments are marshaled to JS values if they are
valueof, just like decorator arguments. -
Return values are unmarshaled from JS into either a Type or a Value based on the declared return type constraint.
-
Return values are not memoized.
- Functions can be called repeatedly with the same arguments from TypeSpec, and the underlying JS function will be called each time the
CallExpressionis checked. - Template alias instantiations do cache their results, so simply declaring a companion templated
aliasthat instantiates to the result of calling a function will serve the common use case of making a template instance with full memoization call a function.
- Functions can be called repeatedly with the same arguments from TypeSpec, and the underlying JS function will be called each time the
-
Exposed in the FFI library via
$functions, alongside$decorators. -
tspdextracts function signatures and emitsgenerated-defsfor validation of the library's types.
To support functions that must return a Value in all cases, an UnknownValue is introduced, which serves additional purposes:
model Something {
region: string = unknown;
}-
unknownis anIndeterminateEntitythat resolves to:- the Type
unknownin type contexts - the Value
UnknownValuein value contexts
- the Type
-
In value contexts, its static type is
never, the bottom type- This means it can be assigned to any value-constrained position
-
Attempts to marshal
unknownto JSON (e.g., for@example) or to JS (e.g., FFI calls) will raise diagnostics. You cannot passUnknownValueto a decorator or use it in a model/op example.
This enables patterns like default-valued fields where the actual default is environment-dependent and not expressible.
This is now allowed for both decorators and functions:
extern fn fnWithRest(x?: string, ...rest: valueof string[]);Previously, optional args could not precede rest args. This limitation has been lifted.
Because functions can return entities, they require slightly different resolution for value-only constraints. Example:
extern fn foo(): valueof string;
const X: string = foo();If the function foo does not resolve an implementation, or if the resolved implementation returns a Type rather than a value, we will get double diagnostics, because the returned entity (or default errorType entity, if no implementation resolved) is both not assignable to the constraint of the return type, and it is not assignable to the constraint on X. Functions that can return types will return errorType by default if the function cannot be called or returns something that violated its constraint, where functions that must return values will return the unknown value.
The new constraint-resolution logic will produce a value if it can, in any case where the constraint must resolve to a value, by returning an unconstrained value (but still raising a diagnostic).
| Aspect | extern dec |
extern fn |
|---|---|---|
| Target | Applied to a declaration | Called in expressions |
| Marshaling | Args into JS | Args into JS, return from JS |
| Return | None | Type or Value |
| JS FFI Exposure | $decorators |
$functions |