XPath is widely known as the query language for XML documents. Most .NET developers associate it with XPathNavigator, XmlDocument, or XDocument. But what most people don't realize is that XPath can work with custom types too.
In this post, I'll show you how to implement XPath for your own types and how it can be useful for searching complex object hierarchies.
#The problem
Imagine you have a rich object model and you want to let users find specific items by typing a query. For example, you might have a hierarchy of folders, files, and metadata:
C#
public class Folder
{
public string Name { get; set; }
public List<Folder> SubFolders { get; set; } = new();
public List<FileItem> Files { get; set; } = new();
}
public class FileItem
{
public string Name { get; set; }
public string ContentType { get; set; }
public Folder Parent { get; set; }
}
A user might want to find all files with a specific name, all subfolders matching a pattern, or navigate to a file by its path. Without XPath, you'd need to write multiple search methods or use LINQ with various predicates.
Using XPath for custom types, you can query your object hierarchy:
C#
var root = new Folder { Name = "root" };
// ... populate with folders and files
// Find all folders
_ = root.CreateNavigator().Select("descendant::Folder");
// Find files by name
_ = root.CreateNavigator().Select("//File[@Name='readme.txt']");
// Find files by content type
_ = root.CreateNavigator().Select("//File[@ContentType='image/png']");
// Navigate by path
_ = root.CreateNavigator().SelectSingleNode("/Folder[@Name='root']/Folder[@Name='docs']/File");
#How XPath works under the hood
The key insight is that XPathNavigator doesn't require XML. It's an abstraction that lets you expose a node-based view of any hierarchical data. When you evaluate an XPath expression, the navigator walks through the nodes and properties of your data structure.
To make your custom types work with XPath, you need three things:
- A class implementing
IXPathNavigable (the entry point) - A class implementing
XPathNavigator (the traversal logic) - A way to expose your data through these interfaces
#Implementing XPath for custom types
The navigator needs to track three pieces of state:
- A
NodeKind enum that distinguishes a virtual document root, an element (Folder or FileItem), and an attribute. - A
NodePosition linked list from the current node back to the tree root (so we can navigate to parent). - An attribute index for when the cursor is positioned on an attribute.
Adding a virtual document root node (like XmlDocument does) is important because it lets XPath expressions starting with / work correctly.
C#
using System.Xml;
using System.Xml.XPath;
// Tracks the current position as a linked list from current node to root.
// Node == null means "document root"; otherwise it holds a Folder or FileItem.
internal sealed class NodePosition(object? node, NodePosition? parent, int indexInParent)
{
public readonly object? Node = node;
public readonly NodePosition? Parent = parent;
public readonly int IndexInParent = indexInParent;
public static List<object> ChildrenOf(object? node) => node switch
{
Folder f => [.. f.SubFolders.Cast<object>(), .. f.Files.Cast<object>()],
_ => [],
};
}
internal enum NodeKind { Document, Element, Attribute }
The XPathNavigator abstract class has many members to implement. The key ones are:
NodeType / LocalName / Name / Value: Describe the current node.NamespaceURI / Prefix / BaseURI / XmlLang / IsEmptyElement: XML metadata (return empty or false if unused).NameTable: Return a shared XmlNameTable instance.Clone(): Return a copy of the navigator at the current position.MoveToFirstChild() / MoveToNext() / MoveToPrevious() / MoveToFirst() / MoveToParent() / MoveToRoot(): Tree navigation.MoveToFirstAttribute() / MoveToNextAttribute(): Attribute navigation.MoveToFirstNamespace() / MoveToNextNamespace(): Namespace navigation (return false if not used).MoveTo(XPathNavigator) / IsSamePosition(XPathNavigator): Cross-navigator identity.MoveToId(string): ID-based lookup (return false if not supported).
Here's a complete implementation:
C#
public sealed class FolderSystemNavigator : XPathNavigator
{
private static readonly XmlNameTable s_nameTable = new NameTable();
private readonly Folder _documentRoot;
private NodeKind _kind;
private NodePosition _position;
private int _attrIndex; // 0=Name, 1=ContentType
internal FolderSystemNavigator(Folder root)
{
_documentRoot = root;
_kind = NodeKind.Document;
_position = new NodePosition(null, null, -1);
}
private FolderSystemNavigator(Folder documentRoot, NodeKind kind, NodePosition position, int attrIndex)
{
_documentRoot = documentRoot;
_kind = kind;
_position = position;
_attrIndex = attrIndex;
}
public override XPathNavigator Clone()
=> new FolderSystemNavigator(_documentRoot, _kind, _position, _attrIndex);
public override XmlNameTable NameTable => s_nameTable;
public override XPathNodeType NodeType => _kind switch
{
NodeKind.Document => XPathNodeType.Root,
NodeKind.Element => XPathNodeType.Element,
NodeKind.Attribute => XPathNodeType.Attribute,
_ => throw new InvalidOperationException(),
};
public override string LocalName => _kind switch
{
NodeKind.Element => _position.Node switch { Folder => "Folder", FileItem => "File", _ => "" },
NodeKind.Attribute => _attrIndex == 0 ? "Name" : "ContentType",
_ => "",
};
public override string Name => LocalName;
public override string NamespaceURI => "";
public override string Prefix => "";
public override string BaseURI => "";
public override string XmlLang => "";
public override bool IsEmptyElement
=> _kind == NodeKind.Element && NodePosition.ChildrenOf(_position.Node).Count == 0;
public override string Value => _kind == NodeKind.Attribute
? (_position.Node, _attrIndex) switch
{
(Folder f, 0) => f.Name,
(FileItem fi, 0) => fi.Name,
(FileItem fi, 1) => fi.ContentType,
_ => "",
}
: "";
// ── Attribute navigation ──────────────────────────────────────────────────
private static int AttrCount(object? node) => node switch
{
Folder => 1, // Name
FileItem => 2, // Name, ContentType
_ => 0,
};
public override bool MoveToFirstAttribute()
{
if (_kind != NodeKind.Element || AttrCount(_position.Node) == 0) return false;
_kind = NodeKind.Attribute;
_attrIndex = 0;
return true;
}
public override bool MoveToNextAttribute()
{
if (_kind != NodeKind.Attribute) return false;
if (_attrIndex + 1 >= AttrCount(_position.Node)) return false;
_attrIndex++;
return true;
}
// ── Namespace navigation (not used) ───────────────────────────────────────
public override bool MoveToFirstNamespace(XPathNamespaceScope scope) => false;
public override bool MoveToNextNamespace(XPathNamespaceScope scope) => false;
// ── Element navigation ────────────────────────────────────────────────────
public override bool MoveToFirstChild()
{
if (_kind == NodeKind.Attribute) return false;
if (_kind == NodeKind.Document)
{
// Document root has the root Folder as its only child
_kind = NodeKind.Element;
_position = new NodePosition(_documentRoot, _position, 0);
return true;
}
var children = NodePosition.ChildrenOf(_position.Node);
if (children.Count == 0) return false;
_position = new NodePosition(children[0], _position, 0);
return true;
}
public override bool MoveToNext()
{
if (_kind != NodeKind.Element || _position.Parent is null) return false;
if (_position.Parent.Node is null) return false; // document root has only one child
var siblings = NodePosition.ChildrenOf(_position.Parent.Node);
var next = _position.IndexInParent + 1;
if (next >= siblings.Count) return false;
_position = new NodePosition(siblings[next], _position.Parent, next);
return true;
}
public override bool MoveToPrevious()
{
if (_kind != NodeKind.Element || _position.Parent is null) return false;
var prev = _position.IndexInParent - 1;
if (prev < 0 || _position.Parent.Node is null) return false;
var siblings = NodePosition.ChildrenOf(_position.Parent.Node);
_position = new NodePosition(siblings[prev], _position.Parent, prev);
return true;
}
public override bool MoveToFirst()
{
if (_kind != NodeKind.Element || _position.Parent is null || _position.IndexInParent == 0)
return _kind == NodeKind.Element && _position.IndexInParent == 0;
if (_position.Parent.Node is null) return false;
var siblings = NodePosition.ChildrenOf(_position.Parent.Node);
_position = new NodePosition(siblings[0], _position.Parent, 0);
return true;
}
public override bool MoveToParent()
{
if (_kind == NodeKind.Attribute)
{
_kind = NodeKind.Element;
_attrIndex = 0;
return true;
}
if (_position.Parent is null) return false;
if (_position.Parent.Node is null)
_kind = NodeKind.Document;
_position = _position.Parent;
return true;
}
public override void MoveToRoot()
{
_kind = NodeKind.Document;
_attrIndex = 0;
while (_position.Parent is not null)
_position = _position.Parent;
}
// ── Identity and cross-navigator ──────────────────────────────────────────
public override bool MoveTo(XPathNavigator other)
{
if (other is FolderSystemNavigator nav && ReferenceEquals(_documentRoot, nav._documentRoot))
{
_kind = nav._kind;
_position = nav._position;
_attrIndex = nav._attrIndex;
return true;
}
return false;
}
public override bool MoveToId(string id) => false;
public override bool IsSamePosition(XPathNavigator other)
{
if (other is not FolderSystemNavigator nav) return false;
if (_kind != nav._kind) return false;
if (!ReferenceEquals(_position.Node, nav._position.Node)) return false;
return _kind != NodeKind.Attribute || _attrIndex == nav._attrIndex;
}
}
#Making types IXPathNavigable
To make your Folder type work with XPath, add a CreateNavigator() method. You can also implement IXPathNavigable to satisfy the standard contract:
C#
public class Folder : IXPathNavigable
{
public string Name { get; set; } = "";
public List<Folder> SubFolders { get; set; } = [];
public List<FileItem> Files { get; set; } = [];
public XPathNavigator CreateNavigator()
=> new FolderSystemNavigator(this);
}
public class FileItem
{
public string Name { get; set; } = "";
public string ContentType { get; set; } = "";
}
Note that FileItem does not need its own navigator. The FolderSystemNavigator handles both Folder and FileItem nodes internally, using pattern matching on the node's runtime type.
#Supporting custom XPath functions
The standard XPath 1.0 functions (contains(), starts-with(), string-length(), etc.) are built in. But you can also define your own functions and use them inside predicates.
For example, a has-extension(ext) function that returns true when a file's name ends with the given extension:
C#
// Usage: //File[has-extension('png')]
var expr = XPathExpression.Compile("//File[has-extension('png')]");
expr.SetContext(new FolderXsltContext());
var it = nav.Select(expr);
Custom functions are wired in through two interfaces from System.Xml.Xsl:
XsltContext — resolves function and variable names to implementations.IXsltContextFunction — implements the actual function logic.
C#
using System.Xml.Xsl;
// XsltContext resolves custom function names to IXsltContextFunction instances.
internal sealed class FolderXsltContext : XsltContext
{
public FolderXsltContext() : base(new NameTable()) { }
public override IXsltContextFunction ResolveFunction(string prefix, string name, XPathResultType[] argTypes)
=> name switch
{
"has-extension" => new HasExtensionFunction(),
_ => throw new XPathException($"Unknown function: {name}"),
};
public override IXsltContextVariable ResolveVariable(string prefix, string name)
=> throw new XPathException($"Unknown variable: ${name}");
public override bool Whitespace => false;
public override bool PreserveWhitespace(XPathNavigator node) => false;
public override int CompareDocument(string baseUri, string nextBaseUri) => 0;
}
// has-extension(ext) returns true when the current File node's name ends with ext.
internal sealed class HasExtensionFunction : IXsltContextFunction
{
public XPathResultType[] ArgTypes => [XPathResultType.String];
public int Minargs => 1;
public int Maxargs => 1;
public XPathResultType ReturnType => XPathResultType.Boolean;
public object Invoke(XsltContext xsltContext, object[] args, XPathNavigator docContext)
{
var extension = "." + (string)args[0];
var name = docContext.GetAttribute("Name", "");
return name.EndsWith(extension, StringComparison.OrdinalIgnoreCase);
}
}
The Invoke method receives the current node via docContext, which is the XPathNavigator positioned at the node being tested. You can read any attribute or navigate the tree from there to implement arbitrarily complex predicates.
To add more functions, simply add cases to the switch in ResolveFunction and create the corresponding IXsltContextFunction class.
#Conclusion
XPath is much more than an XML technology. By implementing IXPathNavigable and XPathNavigator, you can bring the power of XPath queries to any hierarchical data in your application. This gives your users a familiar, powerful way to search and navigate your object models.
The next time you need to let users find items in a complex hierarchy, consider giving them the power of XPath.
#Additional resources
Do you have a question or a suggestion about this post? Contact me!