DataKit 1.0.0

dotnet add package DataKit --version 1.0.0
                    
NuGet\Install-Package DataKit -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="DataKit" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="DataKit" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="DataKit" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add DataKit --version 1.0.0
                    
#r "nuget: DataKit, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package DataKit@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=DataKit&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=DataKit&version=1.0.0
                    
Install as a Cake Tool

DataKit

Unified data objects for .NET — request DTOs, response resources, and validation targets in a single class. Inspired by spatie/laravel-data. Complements JSON:API envelope libraries (like JsonApiKit) by handling the inner resource objects and request binding.

Table of Contents


Overview

DataKit lets you define a single C# class — a DataObject — that acts as a request DTO when receiving data from the client, a response resource when sending data back, and a validation target thanks to standard System.ComponentModel.DataAnnotations attributes.

public class TicketData : DataObject
{
    [InMode(DataMode.Response)]
    public string? Id { get; set; }

    [Required, MaxLength(300)]
    public string Title { get; set; } = string.Empty;

    public string? Priority { get; set; }

    [InMode(DataMode.Response)]
    public string? Status { get; set; }

    [Lazy]
    public UserData? AssignedTo { get; set; }

    [InMode(DataMode.Response)]
    public DateTime? CreatedAt { get; set; }
}

Key concepts:

Concept What it does
[InMode(DataMode.Response)] Property is populated only when building a response; excluded from request binding
[InMode(DataMode.Request)] Property is populated only when reading a request; excluded from response output
[Lazy] Property is excluded unless explicitly requested via an includes set (mirrors ?include= query params)
Optional<T> Distinguishes "field not sent" from "field sent as null" — useful for PATCH endpoints

Packages

Package Description Target Frameworks
DataKit Core types: DataObject, attributes, Optional<T>, transformers, mapper net8.0, net9.0, net10.0
DataKit.AspNetCore Model binding for DataObject in ASP.NET Core MVC/API net8.0, net10.0

Installation

dotnet add package DataKit

For ASP.NET Core projects:

dotnet add package DataKit.AspNetCore

Getting Started

Defining a Data Object

Subclass DataObject and add properties as normal. Standard System.ComponentModel.DataAnnotations attributes work out of the box because ASP.NET Core's validation pipeline sees the data object directly.

using DataKit;
using DataKit.Attributes;
using System.ComponentModel.DataAnnotations;

public class CreateProjectData : DataObject
{
    [Required, MaxLength(200)]
    public string Name { get; set; } = string.Empty;

    [MaxLength(2000)]
    public string? Description { get; set; }

    public string? OwnerId { get; set; }
}

Controlling Visibility with InMode

Decorate properties with [InMode(DataMode.Response)] to include them only when producing outbound responses, or [InMode(DataMode.Request)] to include them only when reading inbound requests. Properties without the attribute are always included.

public class ProjectData : DataObject
{
    // Only returned in API responses — never accepted from the client
    [InMode(DataMode.Response)]
    public string? Id { get; set; }

    [Required, MaxLength(200)]
    public string Name { get; set; } = string.Empty;

    // Only accepted from the client on create/update
    [InMode(DataMode.Request)]
    public string? RequestedByUserId { get; set; }

    [InMode(DataMode.Response)]
    public DateTime? CreatedAt { get; set; }
}

When the mapper runs in DataMode.Response, it skips RequestedByUserId. When it runs in DataMode.Request (i.e. MapFrom), it skips Id and CreatedAt.

Lazy Properties

Decorated with [Lazy], a property is excluded from mapping unless its camelCase name appears in the includes set. This is ideal for related resources that should only be loaded on demand, mirroring the ?include=assignedTo pattern in JSON:API.

public class TicketData : DataObject
{
    public string Title { get; set; } = string.Empty;

    [Lazy]
    public UserData? AssignedTo { get; set; }

    [Lazy]
    public IList<CommentData>? Comments { get; set; }
}
// Include nothing lazy
var data = mapper.MapTo<TicketData>(ticket, DataMode.Response);

// Include only the assigned user
var data = mapper.MapTo<TicketData>(ticket, DataMode.Response,
    includes: new HashSet<string> { "assignedTo" });

Optional<T>

Use Optional<T> on PATCH request data objects to distinguish between "the client did not send this field" and "the client explicitly sent null".

public class PatchTicketData : DataObject
{
    public Optional<string?> Title { get; set; }
    public Optional<string?> Priority { get; set; }
}
// Client sends: { "title": "New title" }
// Priority is Optional.Undefined — we must NOT overwrite the existing value

var patchData = ...; // bound from request
var existing = await repository.GetAsync(id);

var updated = mapper.MapFrom<PatchTicketData, Ticket>(patchData, existing);
// Only Title is updated; Priority is left untouched

Optional<T> has three key members:

Member Description
HasValue true if the field was explicitly set (even to null)
Value The wrapped value (only meaningful when HasValue is true)
Optional<T>.Undefined The default "not sent" state
Optional<T>.From(value) Create a defined optional
GetValueOrDefault(fallback) Returns value if defined, else fallback

Mapping

MapTo — Entity to Data Object

var ticket = await repository.GetAsync(id);

// Response mapping — include lazy AssignedTo
var data = mapper.MapTo<TicketData>(ticket, DataMode.Response,
    includes: new HashSet<string> { "assignedTo" });

MapTo will:

  1. Create a new TData instance
  2. For each writable property on TData, look for a matching property on the source by name (case-insensitive)
  3. Skip properties whose [InMode] does not match the current mode
  4. Skip [Lazy] properties whose name is not in includes
  5. Apply any registered transformer if the source and destination types differ

MapFrom — Data Object to Entity

// Create
var entity = mapper.MapFrom<CreateTicketData, Ticket>(requestData);

// Update (patch)
var existing = await repository.GetAsync(id);
var updated = mapper.MapFrom<PatchTicketData, Ticket>(patchData, existing);

MapFrom will:

  1. Use existing if provided, otherwise create a new TEntity
  2. For each readable property on TData, look for a matching writable property on TEntity
  3. Skip properties whose [InMode] is DataMode.Response (they are output-only)
  4. Skip Optional<T> properties where HasValue is false
  5. Apply a reverse transformer if needed

Transformers

Transformers handle type conversion during mapping — for example, storing a DateTime on the entity but serializing it as an ISO 8601 string on the data object.

services.AddDataKit(options =>
{
    options.AddTransformer<DateTime, string>(
        dt => dt.ToString("O"),
        str => DateTime.Parse(str));

    options.AddTransformer<Guid, string>(
        g => g.ToString(),
        str => Guid.Parse(str));
});

You can also implement IDataTransformer<TFrom, TTo> and register an instance:

public class UlidStringTransformer : IDataTransformer<Ulid, string>
{
    public string Transform(Ulid value) => value.ToString();
    public Ulid ReverseTransform(string value) => Ulid.Parse(value);
}

// Registration
options.AddTransformer<Ulid, string>(new UlidStringTransformer());

Dependency Injection

Register DataKit in your Program.cs or Startup.cs:

using DataKit.DependencyInjection;

builder.Services.AddDataKit(options =>
{
    options.AddTransformer<DateTime, string>(
        dt => dt.ToString("O"),
        str => DateTime.Parse(str));
});

This registers IDataObjectMapper as a singleton. Inject it anywhere:

public class TicketsController(IDataObjectMapper mapper) : ControllerBase
{
    [HttpGet("{id}")]
    public async Task<IActionResult> Get(string id)
    {
        var ticket = await repository.GetAsync(id);
        var data = mapper.MapTo<TicketData>(ticket, DataMode.Response);
        return Ok(data);
    }
}

ASP.NET Core Integration

Model Binding

Install DataKit.AspNetCore and call AddDataKitWithAspNetCore instead of AddDataKit:

using DataKit.AspNetCore.DependencyInjection;

builder.Services.AddDataKitWithAspNetCore(options =>
{
    options.AddTransformer<DateTime, string>(
        dt => dt.ToString("O"),
        str => DateTime.Parse(str));
});

This registers DataObjectModelBinderProvider, which automatically binds JSON request bodies into any DataObject subclass used as a controller action parameter:

[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateTicketData data)
{
    if (!ModelState.IsValid)
        return ValidationProblem();

    var ticket = mapper.MapFrom<CreateTicketData, Ticket>(data);
    await repository.AddAsync(ticket);
    return CreatedAtAction(nameof(Get), new { id = ticket.Id },
        mapper.MapTo<TicketData>(ticket, DataMode.Response));
}

TypeScript Generation

DataKit can generate TypeScript interface definitions from your data objects. This keeps frontend types in sync with the backend without manual duplication.

using DataKit.TypeScript;

var generator = new TypeScriptGenerator();

// Generate to string
string ts = generator.Generate([
    typeof(TicketData),
    typeof(UserData),
    typeof(CreateTicketData),
]);

// Generate to file
generator.GenerateToFile([
    typeof(TicketData),
    typeof(UserData),
], "resources/js/types/data.ts");

Example output:

// <auto-generated />
// Generated by DataKit TypeScriptGenerator

export interface TicketData {
  id?: string; // inMode: Response
  title: string;
  priority?: string;
  status?: string; // inMode: Response
  assignedTo?: UserData; // lazy
  createdAt?: string; // inMode: Response
}

export interface UserData {
  id?: string; // inMode: Response
  name: string;
  email: string;
}

Type mappings:

C# type TypeScript type
string string
bool boolean
int, long, float, double, decimal number
DateTime, DateTimeOffset, DateOnly string
Guid string
T? / Nullable<T> T? (optional)
Optional<T> T? (optional)
List<T>, IEnumerable<T> T[]
Dictionary<K, V> Record<K, V>
DataObject subclass interface reference
anything else unknown

API Reference

DataObject

Abstract base class. Subclass to create a data object.

DataMode

public enum DataMode { Request, Response }

InModeAttribute

[AttributeUsage(AttributeTargets.Property)]
public sealed class InModeAttribute(DataMode mode) : Attribute

LazyAttribute

[AttributeUsage(AttributeTargets.Property)]
public sealed class LazyAttribute : Attribute

Optional<T>

public readonly struct Optional<T>
{
    public bool HasValue { get; }
    public T? Value { get; }
    public static Optional<T> Undefined { get; }
    public static Optional<T> From(T? value);
    public static implicit operator Optional<T>(T? value);
    public T? GetValueOrDefault(T? fallback = default);
}

IDataTransformer<TFrom, TTo>

public interface IDataTransformer<TFrom, TTo>
{
    TTo Transform(TFrom value);
    TFrom ReverseTransform(TTo value);
}

IDataObjectMapper

public interface IDataObjectMapper
{
    TData MapTo<TData>(object source, DataMode mode = DataMode.Response,
        IReadOnlySet<string>? includes = null) where TData : DataObject, new();

    TEntity MapFrom<TData, TEntity>(TData data, TEntity? existing = default)
        where TData : DataObject where TEntity : new();
}

DataKitOptions

public sealed class DataKitOptions
{
    DataKitOptions AddTransformer<TFrom, TTo>(Func<TFrom, TTo> transform, Func<TTo, TFrom> reverseTransform);
    DataKitOptions AddTransformer<TFrom, TTo>(IDataTransformer<TFrom, TTo> transformer);
}

TypeScriptGenerator

public sealed class TypeScriptGenerator
{
    string Generate(IEnumerable<Type> dataTypes);
    void GenerateToFile(IEnumerable<Type> dataTypes, string outputPath);
}

License

MIT License — see LICENSE for details.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on DataKit:

Package Downloads
DataKit.AspNetCore

ASP.NET Core integration for DataKit — model binding, DI helpers, and TypeScript generation endpoint.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 133 3/26/2026