Skip to content
Open
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
19 changes: 19 additions & 0 deletions .chronus/changes/partial-interface-2026-9-9-19-30-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
---

Add support for `partial` interfaces. A `partial interface` can be declared multiple times, including across different files, and every matching declaration must be marked `partial`. All operations, decorators, and `extends` clauses from each declaration are combined into a single interface.

```typespec
// a.tsp
partial interface Widgets {
list(): void;
}

// b.tsp
partial interface Widgets {
read(id: string): void;
}
```
55 changes: 55 additions & 0 deletions packages/compiler/src/core/binder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { mutate } from "../utils/misc.js";
import { compilerAssert } from "./diagnostics.js";
import { isCompilerFeatureEnabled } from "./features.js";
import { getLocationContext } from "./helpers/location-context.js";
import { createDiagnostic } from "./messages.js";
import { visitChildren } from "./parser.js";
import type { Program } from "./program.js";
import type {
Expand Down Expand Up @@ -637,6 +638,12 @@ export function createBinder(program: Program): Binder {
) {
return;
}
if (
flags & SymbolFlags.Interface &&
mergePartialInterfaceDeclarations(node as InterfaceStatementNode, scope)
) {
return;
}
const key = name ?? node.id.sv;
const symbol = createSymbol(node, key, flags, scope.symbol);
mutate(node).symbol = symbol;
Expand All @@ -656,6 +663,12 @@ export function createBinder(program: Program): Binder {
) {
return;
}
if (
flags & SymbolFlags.Interface &&
mergePartialInterfaceDeclarations(node as InterfaceStatementNode, effectiveScope)
) {
return;
}
const key = name ?? node.id.sv;
const symbol = createSymbol(node, key, flags, fileNamespace?.symbol);
mutate(node).symbol = symbol;
Expand Down Expand Up @@ -685,6 +698,48 @@ export function createBinder(program: Program): Binder {
return symbol;
}

/**
* Merge a `partial interface` declaration into the symbol of a previously bound
* declaration of the same name, provided every declaration sharing that name is
* marked `partial`. Once merged, the declarations share a single symbol so that
* their members (operations) end up in the same symbol table, and the checker can
* later combine all of the declarations into a single `Interface` type.
*
* @returns `true` if the node was merged into an existing declaration (in which case
* the caller should not create a new symbol for it), `false` otherwise.
*/
function mergePartialInterfaceDeclarations(node: InterfaceStatementNode, scope: ScopeNode) {
const isPartial = (node.modifierFlags & ModifierFlags.Partial) !== 0;
const existingBinding = scope.symbol.exports!.get(node.id.sv);
if (!existingBinding || !(existingBinding.flags & SymbolFlags.Interface)) {
// No prior declaration with this name: nothing to merge with yet. Even if this
// declaration is `partial`, it becomes the first declaration and is bound normally.
return false;
}

const existingIsPartial = existingBinding.declarations.every(
(decl) => ((decl as InterfaceStatementNode).modifierFlags & ModifierFlags.Partial) !== 0,
);

if (!isPartial || !existingIsPartial) {
program.reportDiagnostic(
createDiagnostic({
code: "partial-interface-mismatch",
format: { name: node.id.sv },
target: node,
}),
);
// Fall through to normal declaration handling, which will register this as a
// duplicate symbol and produce a `duplicate-symbol` diagnostic as well.
return false;
}

// we have an existing binding, so just push this node to its declarations
mutate(existingBinding.declarations).push(node);
mutate(node).symbol = existingBinding;
Comment on lines +737 to +739
return true;
}

function mergeNamespaceDeclarations(node: NamespaceStatementNode, scope: ScopeNode) {
// we are declaring a namespace in either global scope, or a blockless namespace.
const existingBinding = scope.symbol.exports!.get(node.id.sv);
Expand Down
181 changes: 118 additions & 63 deletions packages/compiler/src/core/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7397,13 +7397,17 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
ctx: CheckContext,
targetType: Type,
node: Node & { decorators: readonly DecoratorExpressionNode[] },
options: { includeAugmentDecorators?: boolean } = {},
) {
const { includeAugmentDecorators = true } = options;
const sym = isMemberNode(node)
? (getSymbolForMember(node) ?? node.symbol)
: getMergedSymbol(node.symbol);
const decorators: DecoratorApplication[] = [];

const augmentDecoratorNodes = resolver.getAugmentDecoratorsForSym(sym);
const augmentDecoratorNodes = includeAugmentDecorators
? resolver.getAugmentDecoratorsForSym(sym)
: [];
const decoratorNodes = [
...augmentDecoratorNodes, // the first decorator will be executed at last, so augmented decorator should be placed at first.
...node.decorators,
Expand All @@ -7416,19 +7420,23 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
}

// Doc comment should always be the first decorator in case an explicit @doc must override it.
const docComment = extractMainDoc(targetType);
if (docComment) {
decorators.unshift(createDocFromCommentDecorator("self", docComment));
}
if (targetType.kind === "Operation") {
const returnTypesDocs = extractReturnsDocs(targetType);
if (returnTypesDocs.returns) {
decorators.unshift(createDocFromCommentDecorator("returns", returnTypesDocs.returns));
// Like augment decorators, this is derived from `targetType.node` which is shared across all
// partial declarations, so only add it once to avoid duplicate applications.
if (includeAugmentDecorators) {
const docComment = extractMainDoc(targetType);
if (docComment) {
decorators.unshift(createDocFromCommentDecorator("self", docComment));
}
if (returnTypesDocs.errors) {
decorators.unshift(createDocFromCommentDecorator("errors", returnTypesDocs.errors));
if (targetType.kind === "Operation") {
const returnTypesDocs = extractReturnsDocs(targetType);
if (returnTypesDocs.returns) {
decorators.unshift(createDocFromCommentDecorator("returns", returnTypesDocs.returns));
}
if (returnTypesDocs.errors) {
decorators.unshift(createDocFromCommentDecorator("errors", returnTypesDocs.errors));
}
} else if (targetType.kind === "ModelProperty") {
}
} else if (targetType.kind === "ModelProperty") {
}
return decorators;
}
Expand Down Expand Up @@ -7783,7 +7791,8 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
}

function checkInterface(ctx: CheckContext, node: InterfaceStatementNode): Interface {
const links = getSymbolLinks(node.symbol);
const mergedSymbol = getMergedSymbol(node.symbol);
const links = getSymbolLinks(mergedSymbol);

if (ctx.mapper === undefined && node.templateParameters.length > 0) {
// This is a templated declaration and we are not instantiating it, so we need to update the flags.
Expand All @@ -7794,15 +7803,42 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
// we're not instantiating this interface and we've already checked it
return links.declaredType as Interface;
}

// All of the declarations that make up this interface. For a normal (non-partial)
// interface this is just `[node]`; for a `partial interface` this includes every
// declaration sharing the same symbol, potentially spread across multiple files.
const declarations = mergedSymbol.declarations as InterfaceStatementNode[];

if (ctx.mapper === undefined) {
checkModifiers(program, node);
for (const declNode of declarations) {
checkModifiers(program, declNode);
if (
declNode.modifierFlags & ModifierFlags.Partial &&
declNode.templateParameters.length > 0
) {
reportCheckerDiagnostic(
createDiagnostic({
code: "partial-interface-template",
format: { name: declNode.id.sv },
target: declNode,
}),
);
}
}
}
checkTemplateDeclaration(ctx, node);

// Use the canonical (first-bound) declaration as the type's `.node` rather than
// whichever declaration happened to trigger this check. This keeps `interfaceType.node`
// stable and, critically, ensures `interfaceType.node!.symbol` is always the fully
// merged symbol (whose `.declarations` includes every partial declaration, same-file
// or cross-file) rather than a non-canonical per-file symbol for cross-file merges.
const canonicalNode = declarations[0];

const interfaceType: Interface = createType({
kind: "Interface",
decorators: [],
node,
node: canonicalNode,
namespace: getParentNamespaceType(node),
sourceInterfaces: [],
operations: createRekeyableMap(),
Expand All @@ -7811,44 +7847,59 @@ export function createChecker(program: Program, resolver: NameResolver): Checker

linkType(ctx, links, interfaceType);

interfaceType.decorators = checkDecorators(ctx, interfaceType, node);

const ownMembers = checkInterfaceMembers(ctx, node, interfaceType);
for (const [index, declNode] of declarations.entries()) {
// Augment decorators (`@@dec(Foo, ...)`) target the merged symbol shared by every
// partial declaration, so only resolve/apply them once (on the first declaration)
// to avoid re-running the same augment decorator once per partial declaration.
interfaceType.decorators = interfaceType.decorators.concat(
checkDecorators(ctx, interfaceType, declNode, {
includeAugmentDecorators: index === 0,
}),
);
}

for (const extendsNode of node.extends) {
const extendsType = getTypeForNode(extendsNode, ctx);
if (extendsType.kind !== "Interface") {
reportCheckerDiagnostic(
createDiagnostic({ code: "extends-interface", target: extendsNode }),
);
continue;
}
const ownMembers = checkInterfaceMembers(ctx, declarations, interfaceType);

for (const member of extendsType.operations.values()) {
if (interfaceType.operations.has(member.name)) {
for (const declNode of declarations) {
for (const extendsNode of declNode.extends) {
const extendsType = getTypeForNode(extendsNode, ctx);
if (extendsType.kind !== "Interface") {
reportCheckerDiagnostic(
createDiagnostic({
code: "extends-interface-duplicate",
format: { name: member.name },
target: extendsNode,
}),
createDiagnostic({ code: "extends-interface", target: extendsNode }),
);
continue;
}

const newMember = cloneTypeForSymbol(getMemberSymbol(node.symbol, member.name)!, member, {
interface: interfaceType,
});
// Don't link it it is overritten
if (!ownMembers.has(member.name)) {
linkIndirectMember(ctx, node, newMember);
}
for (const member of extendsType.operations.values()) {
if (interfaceType.operations.has(member.name)) {
reportCheckerDiagnostic(
createDiagnostic({
code: "extends-interface-duplicate",
format: { name: member.name },
target: extendsNode,
}),
);
}

// Clone deprecation information
copyDeprecation(member, newMember);
const newMember = cloneTypeForSymbol(
getMemberSymbol(mergedSymbol, member.name)!,
member,
{
interface: interfaceType,
},
Comment thread
glecaros marked this conversation as resolved.
);
// Don't link it it is overritten
if (!ownMembers.has(member.name)) {
linkIndirectMember(ctx, declNode, newMember);
}

interfaceType.operations.set(newMember.name, newMember);
// Clone deprecation information
copyDeprecation(member, newMember);

interfaceType.operations.set(newMember.name, newMember);
}
interfaceType.sourceInterfaces.push(extendsType);
}
interfaceType.sourceInterfaces.push(extendsType);
}

for (const [key, value] of ownMembers) {
Expand All @@ -7870,32 +7921,36 @@ export function createChecker(program: Program, resolver: NameResolver): Checker

function checkInterfaceMembers(
ctx: CheckContext,
node: InterfaceStatementNode,
declarations: readonly InterfaceStatementNode[],
interfaceType: Interface,
): Map<string, Operation> {
const ownMembers = new Map<string, Operation>();

// Preregister each operation sym links instantiation to make sure there is no race condition when instantiating templated interface
for (const opNode of node.operations) {
const symbol = getSymbolForMember(opNode);
const links = symbol && getSymbolLinks(symbol);
if (links) {
links.instantiations = new TypeInstantiationMap();
for (const declNode of declarations) {
for (const opNode of declNode.operations) {
const symbol = getSymbolForMember(opNode);
const links = symbol && getSymbolLinks(symbol);
if (links) {
links.instantiations = new TypeInstantiationMap();
}
}
}
for (const opNode of node.operations) {
const opType = checkOperation(ctx, opNode, interfaceType);
if (ownMembers.has(opType.name)) {
reportCheckerDiagnostic(
createDiagnostic({
code: "interface-duplicate",
format: { name: opType.name },
target: opNode,
}),
);
continue;
for (const declNode of declarations) {
for (const opNode of declNode.operations) {
const opType = checkOperation(ctx, opNode, interfaceType);
if (ownMembers.has(opType.name)) {
reportCheckerDiagnostic(
createDiagnostic({
code: "interface-duplicate",
format: { name: opType.name },
target: opNode,
}),
);
continue;
}
ownMembers.set(opType.name, opType);
}
ownMembers.set(opType.name, opType);
}
return ownMembers;
}
Expand Down Expand Up @@ -8169,7 +8224,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
return undefined;
}
const name = node.id.sv;
const parentSym = node.parent?.symbol;
const parentSym = node.parent?.symbol && getMergedSymbol(node.parent.symbol);
return parentSym ? getMemberSymbol(parentSym, name) : undefined;
}

Expand Down
14 changes: 13 additions & 1 deletion packages/compiler/src/core/decorator-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
IntrinsicScalarName,
Model,
ModelProperty,
Node,
Scalar,
Type,
} from "./types.js";
Expand Down Expand Up @@ -308,11 +309,22 @@ export function validateDecoratorUniqueOnNode(
) {
compilerAssert("decorators" in type, "Type should have decorators");

// A declaration can be split across multiple nodes when it is declared `partial`
// (currently only interfaces). In that case `type.node` is just the canonical
// declaration, but the same decorator applied once on each partial declaration
// should still be flagged as a duplicate, so check against every node that
// contributes to the underlying symbol rather than only `type.node`.
const ownerNodes: readonly Node[] = type.node?.symbol
? type.node.symbol.declarations
: type.node
? [type.node]
: [];
Comment thread
glecaros marked this conversation as resolved.
Comment on lines +317 to +321

const sameDecorators = type.decorators.filter(
(x) =>
x.decorator === decorator &&
x.node?.kind === SyntaxKind.DecoratorExpression &&
x.node?.parent === type.node,
ownerNodes.includes(x.node?.parent as Node),
);

if (sameDecorators.length > 1) {
Expand Down
Loading
Loading