diff --git a/src/HotChocolate/Core/src/Types/Configuration/TypeDiscoverer.cs b/src/HotChocolate/Core/src/Types/Configuration/TypeDiscoverer.cs
index 7b78444c1ea..c8ce715c9bd 100644
--- a/src/HotChocolate/Core/src/Types/Configuration/TypeDiscoverer.cs
+++ b/src/HotChocolate/Core/src/Types/Configuration/TypeDiscoverer.cs
@@ -1,3 +1,5 @@
+using System.Globalization;
+using System.Text;
using HotChocolate.Properties;
using HotChocolate.Types;
using HotChocolate.Types.Descriptors;
@@ -238,22 +240,46 @@ private void CollectErrors()
if (_errors.Count == 0 && _typeRegistrar.Unresolved.Count > 0)
{
- foreach (var unresolvedReference in _typeRegistrar.Unresolved)
+ var message = new StringBuilder();
+
+ // the unresolved references are stored in an unordered set, so we order
+ // them deterministically to keep the produced error sequence stable.
+ foreach (var unresolvedReference in
+ _typeRegistrar.Unresolved.OrderBy(r => r.ToString(), StringComparer.Ordinal))
{
var types = _typeRegistry.Types.Where(
t => t.Dependencies.Select(d => d.Type)
.Any(r => r.Equals(unresolvedReference))).ToList();
+ var path = TypeInferencePathBuilder.Build(_typeRegistry, unresolvedReference);
+
+ message.Clear();
+
+ message.AppendFormat(
+ CultureInfo.InvariantCulture,
+ TypeResources.TypeRegistrar_TypesInconsistent,
+ unresolvedReference);
+
+ if (path.HasValue)
+ {
+ message.Append(Environment.NewLine);
+ message.Append(Environment.NewLine);
+ message.Append(path.Value.Short);
+ }
+
var builder =
SchemaErrorBuilder.New()
- .SetMessage(
- TypeResources.TypeRegistrar_TypesInconsistent,
- unresolvedReference)
+ .SetMessage(message.ToString())
.SetExtension(
TypeErrorFields.Reference,
unresolvedReference)
.SetCode(ErrorCodes.Schema.UnresolvedTypes);
+ if (path.HasValue)
+ {
+ builder.SetExtension(TypeErrorFields.Path, path.Value.Expanded);
+ }
+
if (types.Count == 1)
{
builder.SetTypeSystemObject(types[0].Type);
diff --git a/src/HotChocolate/Core/src/Types/Configuration/TypeInferencePathBuilder.cs b/src/HotChocolate/Core/src/Types/Configuration/TypeInferencePathBuilder.cs
new file mode 100644
index 00000000000..ff967201109
--- /dev/null
+++ b/src/HotChocolate/Core/src/Types/Configuration/TypeInferencePathBuilder.cs
@@ -0,0 +1,387 @@
+using System.Text;
+using HotChocolate.Types;
+using HotChocolate.Types.Descriptors;
+using HotChocolate.Types.Descriptors.Configurations;
+
+namespace HotChocolate.Configuration;
+
+///
+/// Builds a developer-facing path that describes how an unresolved type reference
+/// was reached during type discovery. The path is composed from runtime (CLR) member
+/// names so that the originating field or argument can be located in the source code.
+///
+internal static class TypeInferencePathBuilder
+{
+ ///
+ /// Builds a path from the root type to the unresolved type reference.
+ ///
+ ///
+ /// The type registry that holds all registered types.
+ ///
+ ///
+ /// The type reference that could not be inferred or resolved.
+ ///
+ ///
+ /// The maximum number of arrow-separated parts in the path, including the leaf
+ /// (and a leading "..." marker when the chain is longer).
+ ///
+ ///
+ /// A that can render a short or a namespace-qualified
+ /// form, or null when no member edge pointing at the unresolved reference can
+ /// be found.
+ ///
+ public static TypeInferencePath? Build(
+ TypeRegistry typeRegistry,
+ TypeReference unresolved,
+ int maxSegments = 5)
+ {
+ ArgumentNullException.ThrowIfNull(typeRegistry);
+ ArgumentNullException.ThrowIfNull(unresolved);
+
+ // the leaf is captured as a runtime type when possible so that it can be
+ // rendered with or without its namespace. otherwise the reference text is used.
+ var leafType = unresolved is ExtendedTypeReference extendedRef
+ ? extendedRef.Type.Type
+ : null;
+ var leafText = leafType is null ? unresolved.ToString()! : null;
+
+ // the first hop locates the member whose type is the unresolved reference.
+ if (!TryFindOwner(typeRegistry, unresolved, out var owner, out var segment))
+ {
+ return null;
+ }
+
+ // the path is built leaf-first and reversed before joining. the leaf counts
+ // towards the cap, as does a leading "..." marker when the chain is longer.
+ var segments = new List { segment };
+ var visited = new HashSet { owner };
+ var truncated = false;
+
+ // the remaining hops walk towards the root by locating the member whose
+ // type resolves to the current owner.
+ while (TryFindParent(typeRegistry, owner, visited, out var parent, out var parentSegment))
+ {
+ // adding this ancestor would fill the last slot. if yet another ancestor
+ // exists beyond it, that slot is reserved for the leading "..." marker.
+ if (segments.Count + 1 >= maxSegments - 1)
+ {
+ visited.Add(parent);
+ truncated = TryFindParent(typeRegistry, parent, visited, out _, out _);
+
+ if (!truncated)
+ {
+ segments.Add(parentSegment);
+ }
+
+ break;
+ }
+
+ segments.Add(parentSegment);
+ visited.Add(parent);
+ owner = parent;
+ }
+
+ segments.Reverse();
+
+ return new TypeInferencePath(leafType, leafText, segments, truncated);
+ }
+
+ private static bool TryFindOwner(
+ TypeRegistry typeRegistry,
+ TypeReference unresolved,
+ out RegisteredType owner,
+ out TypeInferenceSegment segment)
+ {
+ bool Matches(TypeReference reference) => reference.Equals(unresolved);
+
+ foreach (var registeredType in typeRegistry.Types)
+ {
+ if (TryGetMemberSegment(registeredType, Matches, out segment))
+ {
+ owner = registeredType;
+ return true;
+ }
+ }
+
+ owner = null!;
+ segment = default;
+ return false;
+ }
+
+ private static bool TryFindParent(
+ TypeRegistry typeRegistry,
+ RegisteredType target,
+ HashSet visited,
+ out RegisteredType parent,
+ out TypeInferenceSegment segment)
+ {
+ bool Matches(TypeReference reference)
+ => typeRegistry.TryGetType(reference, out var rt) && ReferenceEquals(rt, target);
+
+ foreach (var registeredType in typeRegistry.Types)
+ {
+ if (visited.Contains(registeredType))
+ {
+ continue;
+ }
+
+ if (TryGetMemberSegment(registeredType, Matches, out segment))
+ {
+ parent = registeredType;
+ return true;
+ }
+ }
+
+ parent = null!;
+ segment = default;
+ return false;
+ }
+
+ private static bool TryGetMemberSegment(
+ RegisteredType registeredType,
+ Func matches,
+ out TypeInferenceSegment segment)
+ {
+ var owner = registeredType.RuntimeType;
+
+ // only object, interface and input object types expose member edges with a
+ // populated configuration. any other kind yields no member segment.
+ switch (registeredType.Type)
+ {
+ case ObjectType { Configuration: { } config }:
+ foreach (var field in config.Fields)
+ {
+ var memberName = field.Member?.Name ?? field.Name;
+ if (TryMatchOutputField(owner, memberName, field, matches, out segment))
+ {
+ return true;
+ }
+ }
+ break;
+
+ case InterfaceType { Configuration: { } config }:
+ foreach (var field in config.Fields)
+ {
+ var memberName = field.Member?.Name ?? field.Name;
+ if (TryMatchOutputField(owner, memberName, field, matches, out segment))
+ {
+ return true;
+ }
+ }
+ break;
+
+ case InputObjectType { Configuration: { } config }:
+ foreach (var field in config.Fields)
+ {
+ if (field.Type is { } fieldType && matches(fieldType))
+ {
+ var memberName = field.Property?.Name ?? field.Name;
+ segment = new TypeInferenceSegment(owner, $".{memberName}");
+ return true;
+ }
+ }
+ break;
+ }
+
+ segment = default;
+ return false;
+ }
+
+ private static bool TryMatchOutputField(
+ Type owner,
+ string memberName,
+ OutputFieldConfiguration field,
+ Func matches,
+ out TypeInferenceSegment segment)
+ {
+ if (field.Type is { } fieldType && matches(fieldType))
+ {
+ segment = new TypeInferenceSegment(owner, $".{memberName}");
+ return true;
+ }
+
+ if (field.HasArguments)
+ {
+ foreach (var argument in field.GetArguments())
+ {
+ if (argument.Type is { } argType && matches(argType))
+ {
+ var argName = argument.Parameter?.Name ?? argument.Name;
+ segment = new TypeInferenceSegment(owner, $".{memberName}({argName})");
+ return true;
+ }
+ }
+ }
+
+ segment = default;
+ return false;
+ }
+
+ internal static string GetFriendlyTypeName(Type type, bool includeNamespace)
+ {
+ if (TryGetAlias(type, out var alias))
+ {
+ return alias;
+ }
+
+ if (type.IsArray)
+ {
+ var elementType = type.GetElementType();
+ return elementType is null
+ ? type.Name
+ : $"{GetFriendlyTypeName(elementType, includeNamespace)}[]";
+ }
+
+ if (type.IsByRef)
+ {
+ var elementType = type.GetElementType();
+ return elementType is null
+ ? type.Name
+ : $"{GetFriendlyTypeName(elementType, includeNamespace)}&";
+ }
+
+ if (type.IsPointer)
+ {
+ var elementType = type.GetElementType();
+ return elementType is null
+ ? type.Name
+ : $"{GetFriendlyTypeName(elementType, includeNamespace)}*";
+ }
+
+ if (type.IsGenericType)
+ {
+ var name = type.Name;
+ var backtick = name.IndexOf('`');
+ if (backtick > 0)
+ {
+ name = name[..backtick];
+ }
+
+ if (includeNamespace && !string.IsNullOrEmpty(type.Namespace))
+ {
+ name = $"{type.Namespace}.{name}";
+ }
+
+ var arguments = type.GetGenericArguments();
+ var builder = new StringBuilder(name);
+ builder.Append('<');
+
+ for (var i = 0; i < arguments.Length; i++)
+ {
+ if (i > 0)
+ {
+ builder.Append(", ");
+ }
+
+ builder.Append(GetFriendlyTypeName(arguments[i], includeNamespace));
+ }
+
+ builder.Append('>');
+ return builder.ToString();
+ }
+
+ return includeNamespace && !string.IsNullOrEmpty(type.Namespace)
+ ? $"{type.Namespace}.{type.Name}"
+ : type.Name;
+ }
+
+ private static bool TryGetAlias(Type type, out string alias)
+ {
+ alias = type switch
+ {
+ _ when type == typeof(byte) => "byte",
+ _ when type == typeof(sbyte) => "sbyte",
+ _ when type == typeof(short) => "short",
+ _ when type == typeof(ushort) => "ushort",
+ _ when type == typeof(int) => "int",
+ _ when type == typeof(uint) => "uint",
+ _ when type == typeof(long) => "long",
+ _ when type == typeof(ulong) => "ulong",
+ _ when type == typeof(float) => "float",
+ _ when type == typeof(double) => "double",
+ _ when type == typeof(decimal) => "decimal",
+ _ when type == typeof(bool) => "bool",
+ _ when type == typeof(char) => "char",
+ _ when type == typeof(string) => "string",
+ _ when type == typeof(object) => "object",
+ _ => null!
+ };
+
+ return alias is not null;
+ }
+}
+
+///
+/// Represents a single member hop in a .
+///
+///
+/// The runtime type that declares the member.
+///
+///
+/// The member portion of the segment, for example ".Bar" or ".Baz(input)".
+///
+internal readonly record struct TypeInferenceSegment(Type Owner, string MemberSuffix);
+
+///
+/// Represents how an unresolved type reference was reached during type discovery.
+/// The path can be rendered in a short form (aliases, no namespaces) for messages
+/// and an expanded form (namespace-qualified) for tooling.
+///
+internal readonly record struct TypeInferencePath
+{
+ private const string ArrowSeparator = " -> ";
+ private const string TruncationMarker = "...";
+
+ private readonly Type? _leafType;
+ private readonly string? _leafText;
+ private readonly IReadOnlyList _segments;
+ private readonly bool _truncated;
+
+ public TypeInferencePath(
+ Type? leafType,
+ string? leafText,
+ IReadOnlyList segments,
+ bool truncated)
+ {
+ _leafType = leafType;
+ _leafText = leafText;
+ _segments = segments;
+ _truncated = truncated;
+ }
+
+ ///
+ /// Gets the compact path using primitive aliases and no namespaces.
+ ///
+ public string Short => Render(includeNamespace: false);
+
+ ///
+ /// Gets the namespace-qualified path. Primitive aliases are preserved.
+ ///
+ public string Expanded => Render(includeNamespace: true);
+
+ private string Render(bool includeNamespace)
+ {
+ var builder = new StringBuilder();
+
+ if (_truncated)
+ {
+ builder.Append(TruncationMarker);
+ builder.Append(ArrowSeparator);
+ }
+
+ foreach (var segment in _segments)
+ {
+ builder.Append(
+ TypeInferencePathBuilder.GetFriendlyTypeName(segment.Owner, includeNamespace));
+ builder.Append(segment.MemberSuffix);
+ builder.Append(ArrowSeparator);
+ }
+
+ builder.Append(
+ _leafType is null
+ ? _leafText
+ : TypeInferencePathBuilder.GetFriendlyTypeName(_leafType, includeNamespace));
+
+ return builder.ToString();
+ }
+}
diff --git a/src/HotChocolate/Core/src/Types/TypeErrorFields.cs b/src/HotChocolate/Core/src/Types/TypeErrorFields.cs
index 51e06c796ec..59d38d7172e 100644
--- a/src/HotChocolate/Core/src/Types/TypeErrorFields.cs
+++ b/src/HotChocolate/Core/src/Types/TypeErrorFields.cs
@@ -4,4 +4,5 @@ internal static class TypeErrorFields
{
public const string Reference = "reference";
public const string Definition = "definition";
+ public const string Path = "typePath";
}
diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeDiscovererTests.cs b/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeDiscovererTests.cs
index 2cf41120ba5..fee13d6b86f 100644
--- a/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeDiscovererTests.cs
+++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeDiscovererTests.cs
@@ -281,6 +281,194 @@ public void Can_Infer_Generic_Record_Struct_Input_Type()
Assert.Empty(errors);
}
+ [Fact]
+ public void DiscoverTypes_Should_Append_PathToRoot_When_Nested_Member_Cannot_Be_Inferred()
+ {
+ // arrange
+ // the query reaches an un-inferable interface through two nested members.
+ var context = DescriptorContext.Create();
+ var typeRegistry = new TypeRegistry(context.TypeInterceptor);
+ var typeLookup = new TypeLookup(context.TypeInspector, typeRegistry);
+
+ var typeDiscoverer = new TypeDiscoverer(
+ context,
+ typeRegistry,
+ typeLookup,
+ new HashSet
+ {
+ _typeInspector.GetTypeRef(typeof(NestedQuery), TypeContext.Output)
+ },
+ new AggregateTypeInterceptor());
+
+ // act
+ var errors = typeDiscoverer.DiscoverTypes();
+
+ // assert
+ var error = Assert.Single(errors);
+ Assert.Contains(" -> ", error.Message, StringComparison.Ordinal);
+ new SchemaException(errors).Message.MatchSnapshot();
+ }
+
+ [Fact]
+ public void DiscoverTypes_Should_Expose_TypePath_Extension_When_Member_Cannot_Be_Inferred()
+ {
+ // arrange
+ var context = DescriptorContext.Create();
+ var typeRegistry = new TypeRegistry(context.TypeInterceptor);
+ var typeLookup = new TypeLookup(context.TypeInspector, typeRegistry);
+
+ var typeDiscoverer = new TypeDiscoverer(
+ context,
+ typeRegistry,
+ typeLookup,
+ new HashSet
+ {
+ _typeInspector.GetTypeRef(typeof(NestedQuery), TypeContext.Output)
+ },
+ new AggregateTypeInterceptor());
+
+ // act
+ var errors = typeDiscoverer.DiscoverTypes();
+
+ // assert
+ // the message keeps the short form while the extension is namespace-qualified.
+ var error = Assert.Single(errors);
+ Assert.Contains(
+ "NestedQuery.GetFoo -> FooWithBar.Bar -> BarWithBaz.Baz(input) -> IMyArg",
+ error.Message,
+ StringComparison.Ordinal);
+ Assert.Equal(
+ "HotChocolate.Configuration.NestedQuery.GetFoo "
+ + "-> HotChocolate.Configuration.FooWithBar.Bar "
+ + "-> HotChocolate.Configuration.BarWithBaz.Baz(input) "
+ + "-> HotChocolate.Configuration.IMyArg",
+ Assert.IsType(error.Extensions[TypeErrorFields.Path]));
+ }
+
+ [Fact]
+ public void Build_Should_Use_Friendly_GenericName_When_Leaf_Is_ReadOnlyMemory()
+ {
+ // arrange
+ // ReadOnlyMemory resolves during discovery, so the path is built
+ // directly to demonstrate the friendly leaf name formatting.
+ var context = DescriptorContext.Create();
+ var typeRegistry = new TypeRegistry(context.TypeInterceptor);
+ var typeLookup = new TypeLookup(context.TypeInspector, typeRegistry);
+
+ var typeDiscoverer = new TypeDiscoverer(
+ context,
+ typeRegistry,
+ typeLookup,
+ new HashSet
+ {
+ _typeInspector.GetTypeRef(typeof(MemoryQuery), TypeContext.Output)
+ },
+ new AggregateTypeInterceptor());
+ typeDiscoverer.DiscoverTypes();
+
+ var reference = _typeInspector.GetTypeRef(typeof(ReadOnlyMemory), TypeContext.Input);
+
+ // act
+ var path = TypeInferencePathBuilder.Build(typeRegistry, reference);
+
+ // assert
+ // the short form uses aliases without namespaces, the expanded form qualifies them.
+ Assert.Equal("MemoryQuery.Baz(input) -> ReadOnlyMemory", path?.Short);
+ Assert.Equal(
+ "HotChocolate.Configuration.MemoryQuery.Baz(input) -> System.ReadOnlyMemory",
+ path?.Expanded);
+ }
+
+ [Fact]
+ public void Build_Should_Truncate_With_Marker_When_Chain_Exceeds_Cap()
+ {
+ // arrange
+ // the chain reaches the un-inferable leaf through more hops than the cap allows.
+ var context = DescriptorContext.Create();
+ var typeRegistry = new TypeRegistry(context.TypeInterceptor);
+ var typeLookup = new TypeLookup(context.TypeInspector, typeRegistry);
+
+ var typeDiscoverer = new TypeDiscoverer(
+ context,
+ typeRegistry,
+ typeLookup,
+ new HashSet
+ {
+ _typeInspector.GetTypeRef(typeof(DeepQuery), TypeContext.Output)
+ },
+ new AggregateTypeInterceptor());
+ typeDiscoverer.DiscoverTypes();
+
+ var reference = _typeInspector.GetTypeRef(typeof(IMyArg), TypeContext.Input);
+
+ // act
+ var path = TypeInferencePathBuilder.Build(typeRegistry, reference);
+
+ // assert
+ // both renderings share the same truncation, so the part count is identical.
+ var shortParts = path!.Value.Short.Split(" -> ");
+ var expandedParts = path.Value.Expanded.Split(" -> ");
+ Assert.Equal(5, shortParts.Length);
+ Assert.Equal("...", shortParts[0]);
+ Assert.Equal("IMyArg", shortParts[^1]);
+ Assert.Equal(shortParts.Length, expandedParts.Length);
+ }
+
+ public class DeepQuery
+ {
+ public DeepLevel1 GetLevel1() => throw new NotImplementedException();
+ }
+
+ public class DeepLevel1
+ {
+ public DeepLevel2 Level2 { get; } = null!;
+ }
+
+ public class DeepLevel2
+ {
+ public DeepLevel3 Level3 { get; } = null!;
+ }
+
+ public class DeepLevel3
+ {
+ public DeepLevel4 Level4 { get; } = null!;
+ }
+
+ public class DeepLevel4
+ {
+ public DeepLevel5 Level5 { get; } = null!;
+ }
+
+ public class DeepLevel5
+ {
+ public DeepLevel6 Level6 { get; } = null!;
+ }
+
+ public class DeepLevel6
+ {
+ public string Leaf(IMyArg arg) => throw new NotImplementedException();
+ }
+
+ public class NestedQuery
+ {
+ public FooWithBar GetFoo() => throw new NotImplementedException();
+ }
+
+ public class FooWithBar
+ {
+ public BarWithBaz Bar { get; } = null!;
+ }
+
+ public class BarWithBaz
+ {
+ public string Baz(IMyArg input) => throw new NotImplementedException();
+ }
+
+ public class MemoryQuery
+ {
+ public string Baz(ReadOnlyMemory input) => throw new NotImplementedException();
+ }
+
public class FooType
: ObjectType
{
diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Cannot_Infer_Input_Type.snap b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Cannot_Infer_Input_Type.snap
index 766d897e0f6..b119ee2074c 100644
--- a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Cannot_Infer_Input_Type.snap
+++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Cannot_Infer_Input_Type.snap
@@ -1,3 +1,5 @@
For more details look at the `Errors` property.
-1. Unable to infer or resolve a schema type from the type reference `IMyArg (Input)`. (HotChocolate.Types.ObjectType)
+1. Unable to infer or resolve a schema type from the type reference `IMyArg (Input)`.
+
+QueryWithInferError.Foo(o) -> IMyArg (HotChocolate.Types.ObjectType)
diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.DiscoverTypes_Should_Append_PathToRoot_When_Nested_Member_Cannot_Be_Inferred.snap b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.DiscoverTypes_Should_Append_PathToRoot_When_Nested_Member_Cannot_Be_Inferred.snap
new file mode 100644
index 00000000000..4e68cd9a14e
--- /dev/null
+++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.DiscoverTypes_Should_Append_PathToRoot_When_Nested_Member_Cannot_Be_Inferred.snap
@@ -0,0 +1,5 @@
+For more details look at the `Errors` property.
+
+1. Unable to infer or resolve a schema type from the type reference `IMyArg (Input)`.
+
+NestedQuery.GetFoo -> FooWithBar.Bar -> BarWithBaz.Baz(input) -> IMyArg (HotChocolate.Types.ObjectType)