Compile-time Archetype contracts
In my last post about the ECS migration, I complained about type discoverability with ECS compared to OO. I had already started to experiment with compile-time contract concepts but wasn't sure if it was just object-oriented thinking dying slowly. I now have something that makes me decently happy, but it may still make true ECS devotee's hair stand on end. I'd be curious to hear if and how this might be wrong-headed. Anyway, here's some compile time contracts for Archetypes using Arch-ECS
The Problem
In the object-oriented simulation, the residential building type was a class. If you wanted to know what state it carried, you opened ResidentialZone.cs. The class declared its fields. If you wanted to know what simulation modules applied to it, you found the Modules() switch case. The type was the documentation.
In ECS, a residential zone is an entity with a collection of components: GridPosition, PowerConductor, PowerConsumer, BuildingState, RoadAccessUser, Development, Residence, and optionally Vacancies. None of that is encoded anywhere in a single declaration. It's implicit — the consequence of which components you happen to pass to world.Create(...) when you place a residential building, and which systems happen to query for Residence. Get it slightly wrong, and you get silent bugs: a system that expects Employer doesn't run because you forgot to attach it, and nothing tells you.
The other failure mode is in the systems themselves. A query that asks for GridPosition, Development, Commercial, Employer — are all four of those actually present on commercial buildings? Is Employer optional or required? You have to go read BuildingFactory and cross-reference it with your component definitions. This is fine with five components across two building types. With fifteen components across six building types plus citizens, it becomes a maintenance problem.
Interfaces as Schemas
Interfaces are contracts. Usually it's a contract for functionality, but with properties it can just as easily serve as a Schema contract. I decided to try using C# interfaces to declare what components an archetype carries. Non-nullable property => required. Nullable property => optional.
interface IGridOccupant : IAbstractArcheType
{
GridPosition GridPosition { get; }
PowerConductor PowerConductor { get; }
BuildingState BuildingState { get; }
}
interface IGridOccupantWithRoadAccess : IGridOccupant
{
RoadAccessUser RoadAccessUser { get; }
}
interface IBuilding : IGridOccupantWithRoadAccess
{
PowerConsumer PowerConsumer { get; }
Development Development { get; }
}
interface IResidential : IBuilding, IArcheType
{
Residence Residence { get; }
Vacancies? Vacancies { get; }
}
interface ICommercial : IEmployer, IArcheType
{
Commercial Commercial { get; }
}
IArcheType and IAbstractArcheType are marker interfaces from a small library I'm calling TypedArch for now. The relationship is IArcheType : IAbstractArcheType. The distinction matters: anything that extends IAbstractArcheType participates in the type system and contributes component declarations. Anything that additionally extends IArcheType is a concrete archetype — one that can be registered in the manifest.
IGridOccupant, IBuilding, and IEmployer are all abstract. They define the base component sets for the hierarchy, but an entity with only those components would not satisfy any of the Entity requirements. IResidential, ICommercial, IIndustrial, IPowerPlant, IPowerLine, and IRoad are concrete — they extend their abstract base and add , IArcheType. Not only do we get to use inheritance to define Archetype relationships, using interfaces gets around multiple inheritance problems encountered with OO data class hierarchies. IResidential inherits all the components from IBuilding, IGridOccupantWithRoadAccess, and IGridOccupant without repeating them.
Now I have the documentation I was missing. Whenever I want to know what components a Residence MUST and CAN contain, I look at my interfaces. Open GridOccupant.cs and you can read off the entire component composition of every entity type in the simulation. The hierarchy makes the commonalities explicit. I can tell that anything with road access has power conductance because IGridOccupantWithRoadAccess extends IGridOccupant.
Binding Queries to Archetypes
With data contracts taken care of, let's extend that benefit to our systems, or more specifically their queries. Arch's QueryDescription are archetype unaware — you can ask for any component combination without any validation that it's coherent with what you've defined. TypedQueryDescription<T> wraps it to tie it back to our archetypes:
public class VacancySystem : SimSystem
{
private static readonly TypedQueryDescription<IResidential> VacancyQuery =
TypedQueryDescription.Satisfies<IResidential>()
.WithAll<GridPosition, Vacancies>();
public override void Run(World world)
{
world.Ecs.Query(in VacancyQuery.Inner, (...) => { ... });
}
}
The generic constraint on TypedQueryDescription<T> is where T : IAbstractArcheType, so you can bind to either a concrete archetype or an abstract base. Binding to IResidential here means the validator can check that GridPosition and Vacancies are actually declared on IResidential — which they are, both of them, one inherited and one direct.
You can also bind to an abstract base when the query genuinely applies to all subtypes:
private static readonly TypedQueryDescription<IBuilding> BuildingQuery =
TypedQueryDescription.From<IBuilding>();
From<T>() pre-populates WithAll with every required component on T, so BuildingQuery above is equivalent to .WithAll<GridPosition, PowerConductor, BuildingState, RoadAccessUser, PowerConsumer, Development>(). All six components are inherited through the IBuilding hierarchy. Should T have some optional components and you want them in the query as well, you have to explicitly chain WithAll for those components.
The Satisfies<T>() is not specific to concrete archetypes, nor is From<T>() specific to abstract bases. They just happen to be the two ways you can construct TypedQueryDescription<T>. The latter creates a strict query that requires all required members of an archetype, while the former specifies that remainder of the query components MUST conform to the contract of T.
The one unfortunate syntactic artifact in this is in VacancyQuery.Inner. While C# has implicit conversion operators, the in (pass by readonly reference) doesn't trigger them — you have to explicitly reach for .Inner.
The Manifest and Validation
So far we've defined ArcheTypes and typed queries, but we have not defined what a system actually uses. For this, TypedArch needs to know what archetype and systems exist:
interface IArcheTypes
{
ICitizen Citizen { get; }
IPowerLine PowerLine { get; }
IRoad Road { get; }
IPowerPlant PowerPlant { get; }
IResidential Residential { get; }
ICommercial Commercial { get; }
IIndustrial Industrial { get; }
}
interface ISystems
{
BuildingStateSystem BuildingStateSystem { get; }
CommerceSystem CommerceSystem { get; }
DevelopmentSystem DevelopmentSystem { get; }
// ...
}
public class World
{
private readonly TypedWorld<IArcheTypes, ISystems> _typedEcs = new();
public ArchWorld Ecs => _typedEcs.Inner;
// ...
}
TypedWorld<TArchetypes, TSystems> registers the archetypes in declaration order, inspects each system, and from then on knows the full picture. The TypedWorld wrapper exists for build time safety and does not affect runtime performance. Instead TypedWorld has a Validate() method on it meant for post-build invocation to produce warnings and errors as a post-build step. It run three checks across all systems:
General satisfiability. Every TypedQueryDescription field on every system must be satisfiable by at least one registered archetype. A query that can never match anything is always a bug.
Typed query conformance. Every TypedQueryDescription<T> must only query components actually declared on T. Asking for Employer when bound to IResidential is an error — IResidential doesn't declare Employer.
Dead optionals. Optional components that are declared on an archetype but never queried by any system get a warning. Often this is fine (you're checking for the component's presence rather than its value), but it surfaces genuinely unused components too.
Post-Build Validation
The tricky bit with post-build validation is that TypedWorld and the manifest are in CitySim.Simulation.dll, which is a class library. To run the validation, there exists a generic executable, TypedArch.Validator, that loads any target assembly, finds every TypedWorld<,> field or property declaration in it via reflection, extracts the type arguments, and runs Validate() on a freshly constructed instance:
var discovered = (
from type in assembly.GetTypes()
from member in type.GetMembers(allMembers)
let memberType = member is FieldInfo f ? f.FieldType
: member is PropertyInfo p ? p.PropertyType : null
where memberType is { IsGenericType: true }
&& memberType.GetGenericTypeDefinition() == typeof(TypedWorld<,>)
select memberType.GetGenericArguments()
).Distinct(TypeArgArrayEqualityComparer.Instance).ToList();
The TypedWorld<IArcheTypes, ISystems> field in CitySim.Simulation.Model.World is the source of truth. The validator finds it automatically — there's nothing extra to declare. To wire it into the build for CitySim.Simulation we add the following to the .csproj:
<Target Name="ValidateArchetypes" AfterTargets="Build">
<MSBuild Projects="$(MSBuildThisFileDirectory)..\TypedArch.Validator\TypedArch.Validator.csproj"
Targets="Build"
Properties="Configuration=$(Configuration);BuildProjectReferences=false" />
<Exec Command="dotnet "...TypedArch.Validator.dll" "$(TargetPath)""
ConsoleToMSBuild="true" />
</Target>
BuildProjectReferences=false avoids the obvious circular dependency: the validator project references TypedArch (already built as a transitive dependency of the simulation), so it builds fine without re-triggering the simulation's own post-build. You also need <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> on the target project so the validator can find all the dependency assemblies it needs to load — AssemblyDependencyResolver only works reliably for executables, not class libraries.
When something goes wrong, the build tells you:
error : System 'HomelessnessSystem' has query [WithAll<CitizenInfo>, WithNone<Residency>]
that no archetype can satisfy — it will never match.
error : System 'HomelessnessSystem' has a query bound to 'ICitizen' which is not registered.
Those two errors fired the first time I wired up the real system list. ICitizen was defined but missing from the IArcheTypes manifest. The "never match" error was the consequence of that — with no registered ICitizen, the query had no satisfying archetype. Both lines pointed at the exact problem. That's roughly the experience I was hoping for.
Is This Just OOP Clinging On?
I asked myself this honestly. Interfaces, inheritance hierarchies, typed wrappers around raw queries — it does look a lot like forcing OO structure onto something that was supposed to be free of it.
My current rationalization is: the system design stays ECS. DevelopmentSystem is still genuinely unaware of residents. Adding a new building type is still attaching components, not subclassing. The component data is still separate from behavior. The interfaces aren't changing any of that — they're documenting the implicit contract that already existed in BuildingFactory and in the query descriptions scattered across the system files. I'm not adding coupling; I'm naming something that was always there.
Whether it's worth the extra layer depends on the project. For a simulation that will keep growing and will have multiple collaborators, having the contracts explicit and validated catches real mistakes early. I found more than one during the initial wiring. That said, for a small project with stable structure, the maintenance overhead of keeping the manifest and the abstract archetype hierarchy in sync may not be worth it. YMMV.
One design choice that reinforces the intent: TypedWorld has no entity creation API. I built Create<T>() with runtime component validation, then removed it. The post-build validator catches the same mistakes earlier at zero runtime cost, and Add/Remove guards in debug are just noise on top of that. If the build passes, you can trust the contracts. TypedWorld ends up being about 60 lines: own the Arch World, register archetypes and systems, expose Validate(). That's the whole thing.
Where This Goes
TypedArch is currently part of the CitySim repository. If it stays useful as the simulation grows I'll probably extract it as a standalone library. Until then, the code is on GitHub alongside the rest of the CitySim work and you can just copy it from there. It is MIT licensed like the rest of the code.
The natural next step would be a Roslyn analyzer that checks Has<T>, Add<T>, and Remove<T> calls inside query lambdas — if you're iterating inside a TypedQueryDescription<IResidential> query, the analyzer knows the archetype and could flag any component reference not declared on IResidential. The hard part is tracing the entity variable from the lambda parameter to the Has<T> call, and the fact that Arch's query API has dozens of overloads. Tractable for the common case, a lot of work for full coverage. The post-build validator already handles the structural mistakes that matter most; the analyzer would catch the subtler ones that slip through to runtime. Whether that's worth the effort depends on how much the simulation grows.