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
Type
implicitly 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 (
MergePatchUpdate
and 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
CallExpression
resolution when the callee is aFunctionType
. -
extern fn
declarations bind to their parent namespace and appear underfunctionDeclarations
in 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
CallExpression
is checked. - Template alias instantiations do cache their results, so simply declaring a companion templated
alias
that 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
. -
tspd
extracts function signatures and emitsgenerated-defs
for 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;
}
-
unknown
is anIndeterminateEntity
that resolves to:- the Type
unknown
in type contexts - the Value
UnknownValue
in 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
unknown
to JSON (e.g., for@example
) or to JS (e.g., FFI calls) will raise diagnostics. You cannot passUnknownValue
to 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 |