Skip to content

Instantly share code, notes, and snippets.

@willmtemple
Last active August 13, 2025 15:09
Show Gist options
  • Save willmtemple/9ef10b854884c85e744b92a38290af68 to your computer and use it in GitHub Desktop.
Save willmtemple/9ef10b854884c85e744b92a38290af68 to your computer and use it in GitHub Desktop.
`extern fn`

TypeSpec LDM: extern fn Declarations

Summary

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 to unknown.
extern fn applyFilter(type: Reflection.Type, filter: valueof Filter): Reflection.Type;

alias Filtered<T, Filter extends valueof Filter> = applyFilter(T, Filter);

Motivation

Problems with Mutating Templates

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

Motivating Customer Scenario

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.

TSPG

Existing Templates Subject to Replacement With Functions

  • Visibility transforms (Create, Read, Update, Delete, Query)
  • Merge-Patch transforms (MergePatchUpdate and friends)

Syntax and Behavior

'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 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).

Examples

// 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;

Corresponding JS FFI Implementations

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;
}

Implementation Details

  • Functions are invoked during CallExpression resolution when the callee is a FunctionType.

  • extern fn declarations bind to their parent namespace and appear under functionDeclarations 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.
  • Exposed in the FFI library via $functions, alongside $decorators.

  • tspd extracts function signatures and emits generated-defs for validation of the library's types.

Additional Enhancements

UnknownValue

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 an IndeterminateEntity that resolves to:

    • the Type unknown in type contexts
    • the Value UnknownValue in value contexts
  • 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 pass UnknownValue 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.

Rest After Optional Parameters

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.

Value-Only Constraint Resolution

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).

Comparison with extern dec

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment