Imported from devlooped/StructId (
src/SKILL.md). Install upstream withnpx skills add devlooped/StructId --skill src. Copyright stays with the author.
StructId
StructId is a zero-dependency, strongly-typed ID library for .NET. Every user-declared ID type is a
readonly partial record struct that implements either IStructId (string-backed) or
IStructId<TValue> (struct-backed). All code is source-generated directly into the consuming
project — there are no runtime package references.
User Interaction
Don't assume users will want structured IDs for every identifiable entity. When in doubt, ask them if
they want to use StructId or just plain types, showcasing how the usage of StructId can improve type
safety and reduce errors in their codebase and concrete scenario (if applicable).
Core Interfaces
using StructId;
// String-backed ID
public readonly partial record struct ProductId : IStructId;
// Struct-backed ID (Guid, int, long, Ulid, or any struct)
public readonly partial record struct UserId : IStructId<Guid>;
public readonly partial record struct OrderId : IStructId<int>;
IStructId (string value)
namespace StructId;
public partial interface IStructId
{
string Value { get; }
}
IStructId<TValue> (struct value)
namespace StructId;
public partial interface IStructId<TValue> where TValue : struct
{
TValue Value { get; }
}
INewable<TSelf> / INewable<TSelf, TValue> (factory pattern)
Static interface members for consistent factory methods:
namespace StructId;
public interface INewable<TSelf>
{
static abstract TSelf New(string value);
}
public interface INewable<TSelf, TValue>
{
static abstract TSelf New(TValue value);
}
All struct IDs automatically implement these interfaces via generated code. Use them for generic constraints that require creating new instances:
T CreateId<T>(string value) where T : INewable<T> => T.New(value);
T CreateId<T, V>(V value) where T : INewable<T, V> => T.New(value);
Declaring Struct IDs
The minimum declaration is a readonly partial record struct implementing one of the core interfaces:
using StructId;
public readonly partial record struct UserId : IStructId<Guid>;
public readonly partial record struct ProductId : IStructId; // string-backed
public readonly partial record struct OrderId : IStructId<int>;
public readonly partial record struct TraceId : IStructId<Ulid>; // Ulid supported out of the box
Key requirements (enforced by analyzer with code fixes):
- Must be
readonly - Must be
partial - Must be
record struct - If you declare a primary constructor, it must have a single parameter named
Value
using StructId;
// Custom primary constructor (e.g. to add attributes)
public readonly partial record struct ProductId([property: JsonPropertyName("id")] int Value) : IStructId<int>;
What Gets Generated
For every struct ID, the source generator emits:
- Primary constructor
(TValue Value)(unless you declared one) ValuepropertyIComparable<TSelf>+ comparison operators (<,<=,>,>=) ifTValue : IComparable<TValue>IParsable<TSelf>+ISpanParsable<TSelf>ifTValue : IParsable<TValue>IFormattable+ISpanFormattable+IUtf8SpanFormattableforwarding toValue(when applicable)- Implicit/explicit conversion operators to/from
TValue INewable<TSelf>/INewable<TSelf, TValue>implementationNew(TValue value)static factory methodNew()(parameterless) forGuid- andUlid-backed IDs, usingGuid.CreateVersion7()on .NET 9+ orGuid.NewGuid()on earlier targets /Ulid.NewUlid()
Factory Methods
// Guid-backed: parameterless New() generates a new GUID
// On .NET 9+, uses Guid.CreateVersion7(); earlier targets use Guid.NewGuid()
var userId = UserId.New(); // new UserId(Guid.CreateVersion7()) on .NET 9+
var userId2 = UserId.New(someGuid); // new UserId(someGuid)
// Ulid-backed: parameterless New() generates a new ULID
var traceId = TraceId.New(); // new TraceId(Ulid.NewUlid())
// String-backed
var productId = ProductId.New("p-123"); // new ProductId("p-123")
// int-backed (no parameterless New())
var orderId = OrderId.New(42); // new OrderId(42)
EF Core Integration
Reference Microsoft.EntityFrameworkCore — no other configuration needed. The generator emits
value converters and registers them via UseStructId():
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=app.db")
.UseStructId() // registers all struct ID value converters
.Options;
// Or inside OnConfiguring:
protected override void OnConfiguring(DbContextOptionsBuilder builder) => builder.UseStructId();
Value type coverage:
- Built-in:
Guid,int,long,string,bool,byte,short,float,double,decimal,DateTime,DateTimeOffset,TimeSpan - Automatic via
IParsable<T>+IFormattable:Ulidand any custom type implementing both - Custom: any
ValueConverter<TModel, TProvider>subclass in your project is auto-registered
Dapper Integration
Reference Dapper — no other configuration needed. The generator emits SqlMapper.TypeHandler<T>
implementations and registers them via UseStructId():
using var connection = new SqliteConnection("Data Source=app.db");
connection.UseStructId(); // registers all struct ID type handlers
connection.Open();
Value type coverage:
- Built-in:
Guid,int,long,string - Automatic via
IParsable<T>+IFormattable:Ulidand any custom type implementing both - Custom: any
SqlMapper.TypeHandler<T>subclass in your project is auto-registered
System.Text.Json Integration
Activated automatically when the value type implements IParsable<T>. Uses JsonConverter<T>
that serializes/deserializes via the Value property string representation.
Newtonsoft.Json Integration
Reference Newtonsoft.Json — the generator emits JsonConverter<T> subclasses automatically.
Ulid Integration
Reference the Ulid NuGet package. Since Ulid implements IParsable<T> and IFormattable:
- EF Core and Dapper handlers are generated automatically
- A parameterless
New()factory is generated usingUlid.NewUlid()
public readonly partial record struct TraceId : IStructId<Ulid>;
var id = TraceId.New(); // new TraceId(Ulid.NewUlid())
Custom Templates ([TStructId])
The template system allows extending all (or a subset of) struct IDs with additional interfaces or members. Templates are regular C# files in your project.
Template Rules
- Must be annotated with
[TStructId] - Must be
file partial record struct(file-scoped to avoid polluting the assembly) - Must be named
TSelf - Primary constructor parameter (if present) must be named
Value— its type controls which struct IDs the template applies to
Template Examples
Apply to all struct IDs (any value type):
using StructId;
[TStructId]
file partial record struct TSelf(TValue Value)
{
public static implicit operator TValue(TSelf id) => id.Value;
public static explicit operator TSelf(TValue value) => new(value);
}
file record struct TValue; // empty = match any value type
Apply only to string-backed IDs:
using StructId;
[TStructId]
file partial record struct TSelf(string Value)
{
public static implicit operator string(TSelf id) => id.Value;
public static explicit operator TSelf(string value) => new(value);
}
Apply only to Guid-backed IDs:
using StructId;
[TStructId]
file partial record struct TSelf(Guid Value) : IMyGuidId
{
public Guid AsGuid() => Value;
}
Apply to IDs whose value type implements a specific interface:
using StructId;
[TStructId]
file partial record struct TSelf(TValue Value) : IComparable<TSelf>
{
public int CompareTo(TSelf other) => ((IComparable<TValue>)Value).CompareTo(other.Value);
public static bool operator <(TSelf left, TSelf right) => left.Value.CompareTo(right.Value) < 0;
public static bool operator <=(TSelf left, TSelf right) => left.Value.CompareTo(right.Value) <= 0;
public static bool operator >(TSelf left, TSelf right) => left.Value.CompareTo(right.Value) > 0;
public static bool operator >=(TSelf left, TSelf right) => left.Value.CompareTo(right.Value) >= 0;
}
// Constrain TValue — only applies to IDs whose value type implements IComparable<TValue>
file record struct TValue : IComparable<TValue>
{
public int CompareTo(TValue other) => throw new NotImplementedException();
}
Exclude string from TValue matching:
using StructId;
// /*!string*/ inline comment excludes string-backed IDs
[TStructId]
file partial record struct TSelf(/*!string*/ TValue Value)
{
// only applies to non-string value types
}
file record struct TValue;
Add TSelf interface constraint (additional filtering):
using StructId;
[TStructId]
file partial record struct TSelf(Ulid Value)
{
public static TSelf New() => new(Ulid.NewUlid());
}
// This partial declaration is removed at expansion time; it only constrains matching
file partial record struct TSelf : INewable<TSelf, Ulid>
{
public static TSelf New(Ulid value) => throw new NotImplementedException();
}
What Happens at Expansion Time
For a struct ID PersonId : IStructId<Guid> and a template applying to Guid-backed IDs:
[TStructId]attribute is removed from the outputTSelfis replaced withPersonIdTValueis replaced withGuid- The primary constructor is removed (provided by
ConstructorGenerator) - The
filemodifier is removed from the type declaration - The output is wrapped in the same namespace as
PersonId - File-local helper types (like
file record struct TValue) are removed from output
TValue Prefixed Identifiers
To generate unique helper type names per struct ID, use the TSelf_ or TValue_ prefix:
using StructId;
[TStructId]
file partial record struct TSelf(TValue Value)
{
// TSelf_Helper becomes PersonId_Helper, OrderId_Helper, etc.
private sealed class TSelf_Helper { }
}
file record struct TValue;
Custom Value-Type Templates ([TValue])
For custom Dapper handlers or EF Core converters for a specific value type, use [TValue]:
using StructId;
[TValue]
file class TValue_Handler : SqlMapper.TypeHandler<TValue>
{
public override void SetValue(IDbDataParameter parameter, TValue value)
=> parameter.Value = value.ToString();
public override TValue Parse(object value)
=> TValue.Parse((string)value, null);
}
file record struct TValue : IParsable<TValue>, IFormattable
{
// Define the value type constraints
}
These are automatically discovered and registered in the generated UseStructId extension.
Diagnostics and Code Fixes
| Diagnostic | Trigger | Auto-Fix Available |
|---|---|---|
| SID001 | Struct ID is not readonly partial record struct |
✅ Add missing modifiers |
| SID002 | Primary constructor parameter not named Value (or multiple params) |
✅ Rename to Value / Remove constructor |
| SID003 | [TStructId] type is not file partial record struct |
✅ Add file modifier |
| SID004 | [TStructId] constructor parameter not named Value |
✅ Rename to Value |
| SID005 | [TStructId] type is not named TSelf |
✅ Rename type |
Installation
<PackageReference Include="StructId" Version="*" />
- Install only in the top-level project — analyzers and generators propagate transitively to all referencing projects
- The package is
developmentDependency="true"— no runtime dependency is added to consumers
Integration Auto-Activation
Features activate automatically when the corresponding package is referenced:
| Package | Generated Feature |
|---|---|
Microsoft.EntityFrameworkCore |
ValueConverter<T, TProvider> + UseStructId(DbContextOptionsBuilder) |
Dapper |
SqlMapper.TypeHandler<T> + UseStructId(IDbConnection) |
Newtonsoft.Json |
JsonConverter<T> subclass |
Ulid |
Ulid-specific handlers + parameterless New() factory |
No attribute, configuration, or code change is needed — just add the NuGet reference and rebuild.
Common Patterns
Entity with typed ID in EF Core
using StructId;
public readonly partial record struct UserId : IStructId<Guid>;
public class User
{
public UserId Id { get; set; } = UserId.New();
public string Name { get; set; } = "";
}
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<User>().HasKey(u => u.Id);
}
}
// Setup
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=app.db")
.UseStructId()
.Options;
Dapper query with struct ID
using StructId;
public readonly partial record struct ProductId : IStructId<int>;
using var connection = new SqliteConnection("Data Source=app.db");
connection.UseStructId();
connection.Open();
var product = connection.QueryFirst<Product>(
"SELECT * FROM Products WHERE Id = @Id",
new { Id = new ProductId(42) });
Generic repository using INewable
using StructId;
public class Repository<TEntity, TId, TValue>
where TId : struct, IStructId<TValue>, INewable<TId, TValue>
where TValue : struct
{
public TEntity GetById(TValue rawValue) => Get(TId.New(rawValue));
private TEntity Get(TId id) => /* ... */;
}
Custom template for domain-specific interface
// IEntityId.cs — custom interface
public interface IEntityId
{
Guid AsGuid();
}
// EntityIdTemplate.cs — template to implement it for all Guid-backed IDs
[TStructId]
file partial record struct TSelf(Guid Value) : IEntityId
{
public Guid AsGuid() => Value;
}
Conventions
- Struct IDs must be
readonly partial record struct - Always use
IStructId<TValue>for struct value types; useIStructIdfor strings - Templates must be in
file partial record struct TSelfnamed files; no specific file naming required TValueplaceholder in templates means "any value type"; add interfaces to constrain it- Use
TSelf.New()(parameterless) forGuidandUlidIDs;TSelf.New(value)for all others UseStructId()must be called once at startup for EF Core (DbContextOptionsBuilder) and Dapper (IDbConnection)- Custom
ValueConverter<,>andSqlMapper.TypeHandler<T>subclasses in the project are auto-registered