Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/compiler"
---

Validate function rest arguments and report function call argument count diagnostics at call sites.
7 changes: 7 additions & 0 deletions .chronus/changes/witemple-msft-fn-fixes-2026-4-28-13-33-49.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/tspd"
---

Render function type signatures with arrow syntax and avoid internal compiler imports.
27 changes: 19 additions & 8 deletions packages/compiler/src/core/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5815,7 +5815,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
node: CallExpressionNode,
target: FunctionValue<unknown[]>,
): Type | Value | null {
const [satisfied, resolvedArgs] = checkFunctionCallArguments(ctx, node.arguments, target);
const [satisfied, resolvedArgs] = checkFunctionCallArguments(ctx, node.arguments, target, node);

const canCall =
satisfied &&
Expand Down Expand Up @@ -5917,6 +5917,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
ctx: CheckContext,
args: Expression[],
target: FunctionValue,
diagnosticTarget: Node,
): [boolean, any[]] {
let satisfied = true;
const minArgs = target.parameters.filter((p) => !p.optional && !p.rest).length;
Expand All @@ -5930,7 +5931,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
code: "invalid-argument-count",
messageId: "atLeast",
format: { actual: args.length.toString(), expected: minArgs.toString() },
target: target.node!,
target: diagnosticTarget,
}),
);
return [false, []];
Expand All @@ -5939,7 +5940,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
createDiagnostic({
code: "invalid-argument-count",
format: { actual: args.length.toString(), expected: maxArgs.toString() },
target: target.node!,
target: diagnosticTarget,
}),
);
// This error doesn't actually prevent us from checking the arguments and evaluating the function.
Expand Down Expand Up @@ -5971,11 +5972,21 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
continue;
}

resolvedArgs.push(
...restArgs.map((v, idx) =>
v !== null && isValue(v) ? marshalTypeForJs(v, undefined) : v,
),
);
for (const [idx, restArg] of restArgs.entries()) {
const resolved = collector.pipe(
checkEntityAssignableToConstraint(restArg!, constraint, restArgExpressions[idx]),
);

satisfied &&= !!resolved;

resolvedArgs.push(
resolved
? isValue(resolved)
? marshalTypeForJs(resolved, undefined)
: resolved
: undefined,
);
}
} else {
const arg = args[idx++];

Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/src/core/js-marshaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function numericValueToJs(type: NumericValue, valueConstraint: Type | undefined)
const asNumber = type.value.asNumber();
compilerAssert(
asNumber !== null,
`Numeric value '${type.value.toString()}' is not a able to convert to a number without losing precision.`,
`Numeric value '${type.value.toString()}' is not able to convert to a number without losing precision.`,
);
return asNumber;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/src/core/type-relation-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T
relationCache: MultiKeyMap<[Entity, Entity], Related>,
): [Related, readonly TypeRelationError[]] {
if (source.kind !== "FunctionType") {
return [Related.false, [createUnassignableDiagnostic(source, target, source)]];
return [Related.false, [createUnassignableDiagnostic(source, target, diagnosticTarget)]];
}

const { parameters: sourceParameters, returnType: sourceReturnType } = source;
Expand Down
1 change: 1 addition & 0 deletions packages/compiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ export type {
FunctionContext,
FunctionParameter,
FunctionParameterBase,
FunctionType,
FunctionValue,
IdentifierContext,
IdentifierKind,
Expand Down
28 changes: 5 additions & 23 deletions packages/compiler/src/server/type-signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,35 +121,17 @@ function getTypeSignature(type: Type, options: GetSymbolSignatureOptions): strin
case "Tuple":
return `(tuple)\n[${fence(type.values.map((v) => getTypeSignature(v, options)).join(", "))}]`;
case "FunctionType":
return `fn (${type.parameters.map((p) => getTypeSignature(p, options)).join(", ")}): ${getMixedConstraintSignature(
type.returnType,
options,
)}`;
return fence(
`fn (${type.parameters.map((p) => getFunctionParameterSignature(p)).join(", ")}) => ${getEntityName(
type.returnType,
)}`,
);
default:
const _assertNever: never = type;
compilerAssert(false, "Unexpected type kind");
}
}

function getMixedConstraintSignature(
constraint: MixedParameterConstraint,
options: GetSymbolSignatureOptions,
) {
let result = "";

if (constraint.type) {
result += getTypeSignature(constraint.type, options);
}

if (constraint.valueType) {
if (result.length > 0) {
result += " | ";
}
result += "valueof " + getTypeSignature(constraint.valueType, options);
}
return result;
}

/** Format `T extends ...` style signatures for template parameters/access paths. */
function getTemplateConstraintSignature(
nameOrPath: string,
Expand Down
79 changes: 67 additions & 12 deletions packages/compiler/test/checker/functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type Tester,
expectDiagnosticEmpty,
expectDiagnostics,
extractSquiggles,
mockFile,
t,
} from "../../src/testing/index.js";
Expand Down Expand Up @@ -329,21 +330,40 @@ describe("usage", () => {
});

it("errors if not enough args", async () => {
const v = await expectFunctionValueUsage(
"extern fn testFn(a: valueof string, b: valueof string): valueof string",
'testFn("one")',
[
{
code: "invalid-argument-count",
message: "Expected at least 2 arguments, but got 1.",
},
],
const { source, pos, end } = extractSquiggles(
`
import "./test.js";
using TypeSpec.Reflection;

extern fn testFn(a: valueof string, b: valueof string): valueof string;

model Observer {
p: unknown = ┆testFn("one")┆;
}
`,
"┆",
);
const [, diagnostics] = await BaseTester.files({
"test.js": mockFile.js({
$functions: {
"": {
testFn(ctx: FunctionContext, a: any, b: any, ...rest: any[]) {
calledArgs = [ctx, a, b, ...rest];
return a;
},
},
},
}),
}).compileAndDiagnose(source);

expectNotCalled();
expectFunctionDiagnostics(diagnostics, {
code: "invalid-argument-count",
message: "Expected at least 2 arguments, but got 1.",
pos,
end,
});

// Because the const is invalid (transposed to null in the checker), we expect no default value.
strictEqual(v, undefined);
expectNotCalled();
});

it("errors if too many args", async () => {
Expand Down Expand Up @@ -384,6 +404,24 @@ describe("usage", () => {
strictEqual(t.name, "unknown");
});

it("errors if rest argument type mismatches", async () => {
const t = await expectFunctionTypeUsage(
"extern fn testFn(a: string, ...rest: string[])",
'testFn("a", 123)',
[
{
code: "unassignable",
message: "Type '123' is not assignable to type 'string'",
},
],
);

expectNotCalled();

strictEqual(t.kind, "Intrinsic");
strictEqual(t.name, "unknown");
});

it("errors if argument type mismatch (value)", async () => {
const v = await expectFunctionValueUsage(
"extern fn valFirst(a: valueof string): valueof string",
Expand Down Expand Up @@ -1537,6 +1575,23 @@ describe("assignability of functions to fn types", () => {
"Type 'fn (a: string) => string' is not assignable to type 'fn (arg: never) => int32'\n Type 'string' is not assignable to type 'int32'",
});
});

it("reports non-function assignability errors at the assignment expression", async () => {
const { source, pos, end } = extractSquiggles(
`
const f: fn() => unknown = ┆123┆;
`,
"┆",
);
const diagnostics = await BaseTester.diagnose(source);

expectDiagnostics(diagnostics, {
code: "unassignable",
message: "Type '123' is not assignable to type 'fn () => unknown'",
pos,
end,
});
});
});

describe("function type assignability", () => {
Expand Down
16 changes: 16 additions & 0 deletions packages/compiler/test/server/get-hover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,22 @@ describe("compiler: server: on hover", () => {
});
});

it("model property with function type", async () => {
const hover = await getHoverAtCursor(`
model Test {
fu┆nc: fn(v: valueof string) => valueof string;
}
`);

deepStrictEqual(hover, {
contents: {
kind: MarkupKind.Markdown,
value:
"(model property)\n```typespec\nTest.func: fn (v: valueof string) => valueof string\n```",
},
});
});

it("model reference", async () => {
const hover = await getHoverAtCursor(
`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,6 @@ function getDocComment(entity: Entity & { node?: { docs?: readonly DocNode[] } }
}
}
for (const tag of doc.tags) {
tagLines.push();

let first = true;
const hasContentFirstLine = checkIfTagHasDocOnSameLine(tag);
const tagStart =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CompilerHost,
Decorator,
Diagnostic,
type FunctionValue,
Namespace,
type PackageJson,
Program,
Expand All @@ -19,7 +20,6 @@ import {
resolvePath,
} from "@typespec/compiler";
import prettier from "prettier";
import { FunctionValue } from "../../../compiler/src/core/types.js";
import { createDiagnostic } from "../ref-doc/lib.js";
import { generateSignatures } from "./components/entity-signatures.js";
import { DecoratorSignature, EntitySignature, FunctionSignature } from "./types.js";
Expand Down
4 changes: 2 additions & 2 deletions packages/tspd/src/ref-doc/utils/type-signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Decorator,
EnumMember,
FunctionParameter,
FunctionType,
getEntityName,
getTypeName,
Interface,
Expand All @@ -14,7 +15,6 @@ import {
UnionVariant,
} from "@typespec/compiler";
import { TemplateParameterDeclarationNode } from "@typespec/compiler/ast";
import { FunctionType } from "../../../../compiler/src/core/types.js";

/** @internal */
export function getTypeSignature(type: Type): string {
Expand Down Expand Up @@ -97,7 +97,7 @@ function getDecoratorSignature(type: Decorator) {

function getFunctionSignature(type: FunctionType) {
const parameters = [...type.parameters].map((x) => getFunctionParameterSignature(x));
return `(${parameters.join(", ")}): ${getEntityName(type.returnType)}`;
return `fn (${parameters.join(", ")}) => ${getEntityName(type.returnType)}`;
}

function getInterfaceSignature(type: Interface) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,35 @@ export type Decorators = {
});
});

describe("function signatures", () => {
it("includes @param doc comment", async () => {
await expectSignatures({
code: `
#suppress "experimental-feature"
/**
* Some doc comment
*
* @param arg This is the argument
*/
extern fn simple(arg);`,
expected: `
import type { FunctionContext, Type } from "@typespec/compiler";

/**
* Some doc comment
*
* @param arg This is the argument
*/
export type SimpleFunctionImplementation = (context: FunctionContext, arg: Type) => Type;

export type Functions = {
simple: SimpleFunctionImplementation;
};
`,
});
});
});

it("include @param doc comment", async () => {
await expectSignatures({
code: `
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ When marshalling custom scalar subtypes, the marshalling behavior of the known s
The function context provides the `functionCallTarget` and `getArgumentTarget` helpers.

```ts
import type { FunctionContext, Type } from "typespec/compiler";
import type { FunctionContext, Model, Type } from "@typespec/compiler";
import { reportDiagnostic } from "./lib.js";

export function renamed(ctx: FunctionContext, model: Model, name: string): Model {
Expand Down
4 changes: 2 additions & 2 deletions website/src/content/docs/docs/language-basics/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ evaluate to any model that is assignable to `Foo`.

Function parameters follow the same rules as decorator parameters:

- **Type parameters**: Accept TypeScript types (e.g., `param: string`)
- **Type parameters**: Accept TypeSpec types (e.g., `param: string`)
- **Value parameters**: Accept values using `valueof` (e.g., `param: valueof string`)
- **Mixed parameters**: Can accept both types and values with union syntax

Expand Down Expand Up @@ -218,7 +218,7 @@ extern fn example(v: valueof string): valueof string;
// `Example` is equivalent to `fn (v: valueof string) => valueof string`
alias Example = typeof example;

const f: fn(v: valueof string) => valueof string = f;
const f: fn(v: valueof string) => valueof string = example;
```

### Function type syntax
Expand Down
Loading