diff --git a/.chronus/changes/fix-string-template-fn-call-template-decl-2026-6-23.md b/.chronus/changes/fix-string-template-fn-call-template-decl-2026-6-23.md new file mode 100644 index 00000000000..1b07d5b0959 --- /dev/null +++ b/.chronus/changes/fix-string-template-fn-call-template-decl-2026-6-23.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Fix `Expected type.` internal compiler error when a string template interpolates a function call that references a template parameter on a template declaration (e.g. `@doc("${myFn(T)}") model Crud {}`). The deferred function call now defers the whole template, which is evaluated at instantiation. diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 5552a5cb762..9d2510047ba 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -145,6 +145,7 @@ import { StringTemplateMiddleNode, StringTemplateSpan, StringTemplateSpanLiteral, + StringTemplateSpanNode, StringTemplateSpanValue, StringTemplateTailNode, StringValue, @@ -4677,31 +4678,42 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ): IndeterminateEntity | StringValue | null { let hasType = false; let hasValue = false; - const spanTypeOrValues = node.spans.map( - (span) => [span, checkNode(ctx, span.expression)] as const, - ); + const spanTypeOrValues: (readonly [ + StringTemplateSpanNode, + Type | Value | IndeterminateEntity, + ])[] = []; + for (const span of node.spans) { + const typeOrValue = checkNode(ctx, span.expression); + // A null span is the value-world equivalent of `errorType`: the expression couldn't + // produce a usable value (e.g. an already-reported error, or a function call that can't + // be evaluated yet in a template declaration). Like `checkArrayValue`/`checkObjectValue`, + // propagate the null up so the whole template resolves to null instead of fabricating a + // partial string. It is re-evaluated against the real value when the template is instantiated. + if (typeOrValue === null) { + return null; + } + spanTypeOrValues.push([span, typeOrValue]); + } + for (const [_, typeOrValue] of spanTypeOrValues) { - if (typeOrValue !== null) { - if (isValue(typeOrValue)) { - hasValue = true; - } else if ( - "kind" in typeOrValue && - (typeOrValue.kind === "TemplateParameter" || - typeOrValue.kind === "TemplateParameterAccess") - ) { - if (typeOrValue.constraint) { - if (typeOrValue.constraint.valueType) { - hasValue = true; - } - if (typeOrValue.constraint.type) { - hasType = true; - } - } else { + if (isValue(typeOrValue)) { + hasValue = true; + } else if ( + "kind" in typeOrValue && + (typeOrValue.kind === "TemplateParameter" || typeOrValue.kind === "TemplateParameterAccess") + ) { + if (typeOrValue.constraint) { + if (typeOrValue.constraint.valueType) { + hasValue = true; + } + if (typeOrValue.constraint.type) { hasType = true; } } else { hasType = true; } + } else { + hasType = true; } } @@ -4719,12 +4731,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker let str = node.head.value; for (const [span, typeOrValue] of spanTypeOrValues) { if ( - typeOrValue !== null && - (!("kind" in typeOrValue) || - (typeOrValue.kind !== "TemplateParameter" && - typeOrValue.kind !== "TemplateParameterAccess")) + !("kind" in typeOrValue) || + (typeOrValue.kind !== "TemplateParameter" && + typeOrValue.kind !== "TemplateParameterAccess") ) { - compilerAssert(typeOrValue !== null && isValue(typeOrValue), "Expected value."); + compilerAssert(isValue(typeOrValue), "Expected value."); str += stringifyValueForTemplate(typeOrValue); } str += span.literal.value; @@ -4737,7 +4748,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const spans: StringTemplateSpan[] = [createTemplateSpanLiteral(node.head)]; for (const [span, typeOrValue] of spanTypeOrValues) { - compilerAssert(typeOrValue !== null && !isValue(typeOrValue), "Expected type."); + compilerAssert(!isValue(typeOrValue), "Expected type."); const type = typeOrValue.entityKind === "Indeterminate" ? typeOrValue.type : typeOrValue; const spanValue = createTemplateSpanValue(span.expression, type); diff --git a/packages/compiler/test/checker/string-template.test.ts b/packages/compiler/test/checker/string-template.test.ts index fb8e5247417..77632cb0fb4 100644 --- a/packages/compiler/test/checker/string-template.test.ts +++ b/packages/compiler/test/checker/string-template.test.ts @@ -1,10 +1,11 @@ import { strictEqual } from "assert"; import { describe, it } from "vitest"; -import { StringTemplate } from "../../src/index.js"; +import { getDoc, StringTemplate } from "../../src/index.js"; import { expectDiagnosticEmpty, expectDiagnostics, extractSquiggles, + mockFile, t, } from "../../src/testing/index.js"; import { Tester } from "../tester.js"; @@ -164,3 +165,45 @@ it("emit error if interpolating template access that mixes values and types", as end, }); }); + +describe("interpolating a function call referencing a template parameter", () => { + const fnTester = Tester.files({ + "fn.js": mockFile.js({ + $functions: { + "": { + getName: (_ctx: unknown, type: { name?: string }) => type?.name ?? "deferred", + }, + }, + }), + }) + .import("./fn.js") + .using("TypeSpec.Reflection"); + + it("does not crash when used on a template declaration", async () => { + const diagnostics = await fnTester.diagnose(` + #suppress "experimental-feature" "test" + extern fn getName(type: unknown): valueof string; + + @doc("\${getName(T)}") + model Crud {} + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("resolves the function call when the template is instantiated", async () => { + const { p, program } = await fnTester.compile(t.code` + #suppress "experimental-feature" "test" + extern fn getName(type: unknown): valueof string; + + @doc("\${getName(T)}") + model Crud {} + + model Foo {} + + model Holder { + ${t.modelProperty("p")}: Crud; + } + `); + strictEqual(getDoc(program, p.type), "Foo"); + }); +});