﻿---
title: Plugin-defined variant types
description: Elasticsearch plugins can introduce additional field types, token filters, char filters, analyzers, tokenizers, query types, and aggregation types beyond...
url: https://docs-v3-preview.elastic.dev/elastic/elasticsearch-net/tree/main/reference/plugin-defined-variants
products:
  - Elasticsearch
  - Elasticsearch .NET Client
  - Elasticsearch Client
---

# Plugin-defined variant types
Elasticsearch plugins can introduce additional field types, token filters, char filters, analyzers, tokenizers, query types, and aggregation types beyond the closed set known to the typed client. The .NET client supports these plugin-defined variants through two complementary mechanisms: a zero-configuration **carrier** that round-trips unknown discriminators as raw JSON, and an opt-in **registration API** that maps a plugin discriminator to a caller-supplied CLR type for strongly-typed access.
- [Round-trip carrier](#carrier)
  - [Reading a plugin field type](#carrier-read)
- [Writing a plugin token filter](#carrier-write)
- [Registering a strongly-typed CLR variant](#register)
- [Container variants](#containers)
- [Limitations](#limitations)


## Round-trip carrier

For every non-container variant family that accepts plugin-defined values, the client emits a public `Unknown{Family}` class that implements the family's interface. When the deserializer encounters a discriminator value that is not part of the closed variant set and that has not been registered, it produces an instance of this carrier instead of throwing.

### Reading a plugin field type

```csharp
using System.Text;
using System.Linq;
using Elastic.Clients.Elasticsearch;
using Elastic.Clients.Elasticsearch.Mapping;
using Elastic.Transport.Extensions;

var json = """
{
  "type": "truncated_collation",
  "max_length": 64,
  "rules": "latin_to_base"
}
""";

var property = client.RequestResponseSerializer.Deserialize<IProperty>(json)!;

if (property is UnknownProperty unknown)
{
    Console.WriteLine(unknown.Type); 
    Console.WriteLine(unknown.Content.GetProperty("max_length")); 
    Console.WriteLine(unknown.Content.EnumerateObject().Count()); 
}
```


### Writing a plugin token filter

```csharp
using System.Text.Json;
using Elastic.Clients.Elasticsearch.Analysis;

using var document = JsonDocument.Parse("""
{
  "type": "sql_normalizer",
  "preserve_original": true
}
""");
var content = document.RootElement.Clone();

var filter = new UnknownTokenFilter("sql_normalizer", content);

var json = client.RequestResponseSerializer.SerializeToString<ITokenFilter>(filter);
// {"type":"sql_normalizer","preserve_original":true}
```

Construct the carrier with the discriminator the server-side plugin expects and a raw JSON object containing the plugin's configuration. Serializing via the union interface (`ITokenFilter`) causes the converter to write the discriminator first, then every other property from `Content`.

## Registering a strongly-typed CLR variant

When the same plugin variant appears in several places, working with raw `JsonElement` content can become awkward. For these cases the client settings expose a per-instance registry, `ElasticsearchClientSettings.Variants`, whose `Register<TVariantFamily, TImplementation>(string discriminator)` method binds a caller-defined CLR type to a discriminator of the variant family `TVariantFamily`. After registration the deserializer produces instances of the CLR type directly. The serializer writes them using the converter resolved from the client's `JsonSerializerOptions`. Because the registry lives on the settings instance, different `ElasticsearchClient` instances in the same process can register different CLR types (or none) independently.
```csharp
using System.Text.Json.Serialization;
using Elastic.Clients.Elasticsearch.Mapping;

public sealed class TruncatedCollationProperty : IProperty
{
    public string Type => "truncated_collation";

    [JsonPropertyName("max_length")]
    public int? MaxLength { get; set; }

    public string? Rules { get; set; }
}

// Register on the client settings at application startup, then build the client from those settings:
var settings = new ElasticsearchClientSettings(/* ... */);
settings.Variants.Register<IProperty, TruncatedCollationProperty>("truncated_collation");
var client = new ElasticsearchClient(settings);

// Subsequent (de)serialization of IProperty values whose discriminator is "truncated_collation"
// resolves to the registered CLR type:
var property = client.RequestResponseSerializer.Deserialize<IProperty>(json)!;
var typed = (TruncatedCollationProperty)property;
```

<warning>
  For AOT-published applications, a `[JsonConverter]` attribute is **required** on the registered type. The reflection-based fallback used in non-AOT applications cannot be trimmed safely.Source-generated `JsonSerializerContext` implementations cannot be used. A context registers its type metadata on a specific `JsonSerializerOptions` instance, and there is currently no way to compose a user-supplied context with the client's internal serializer options.
</warning>

`Variants.Register<TVariantFamily, TImplementation>(string)` is scoped to the settings instance. Calling it again with the same discriminator replaces the previous registration on that instance. On write, the value's `Type` must match the registered discriminator. A registered discriminator overrides the carrier fallback. Discriminators that are not registered continue to land in `UnknownProperty`.

## Container variants

Container types (for example `Query` or `Aggregation`) don't use a separate `Unknown{Family}` carrier. The container itself holds the variant. Known variants are reached through their typed accessors (`Query.Match`, `Query.Bool`, and so on). For plugin-defined variants the container exposes three additional members:
- `string? VariantName { get; }`: the JSON property name of the variant the container currently holds.
- `T? GetCustomVariant<T>(string variantName)`: returns the stored variant when the container's current name matches, otherwise `null`.
- `void SetCustomVariant<T>(string variantName, T? value)`: sets the container's variant to the given `(name, value)` pair.
- Fluent descriptors expose `CustomVariant<T>(string variantName, T? value)` for writing plugin-defined variants in descriptor-based APIs.

```csharp
using Elastic.Clients.Elasticsearch.QueryDsl;

public sealed class MyCustomQuery
{
    public string Field { get; set; } = "";
    public string Pattern { get; set; } = "";
}

var settings = new ElasticsearchClientSettings(/* ... */);
settings.Variants.RegisterContainer<Query, MyCustomQuery>("my_custom_query");
var client = new ElasticsearchClient(settings);

// Read side
var json = """{ "my_custom_query": { "field": "title", "pattern": "^foo" } }""";
var q = client.RequestResponseSerializer.Deserialize<Query>(json)!;

var typed = q.GetCustomVariant<MyCustomQuery>("my_custom_query");
Console.WriteLine($"name = {q.VariantName}, field = {typed?.Field}");

// Write side
var outgoing = new Query();
outgoing.SetCustomVariant("my_custom_query", new MyCustomQuery { Field = "title", Pattern = "^foo" });

// Descriptor write side
Query descriptorQuery = new QueryDescriptor<object>()
    .CustomVariant("my_custom_query", new MyCustomQuery { Field = "title", Pattern = "^foo" });
```

Register the CLR type for the variant name on the client settings at application startup. Deserialization then produces instances of `MyCustomQuery` rather than a raw `JsonElement`. `GetCustomVariant<T>(variantName)` returns `null` when the container's variant doesn't match the requested name, for example when the container holds a known typed variant instead. `SetCustomVariant<T>(variantName, value)` and the descriptor `CustomVariant<T>(variantName, value)` are intended for plugin-defined variants. Known variants are still set through their typed properties (`outgoing.Match = ...`).

## Limitations

The registration mechanism captures the registered CLR type as a generic type argument inside a static delegate. There is no runtime use of `JsonSerializer.Deserialize(Type)` or `JsonSerializer.Serialize(object, Type)`. For this to remain trim-safe in AOT-published applications, the registered type must be discoverable by the client's serializer options without reflection. In practice, this means a `[JsonConverter]` attribute on the type is required for AOT. A source-generated `JsonSerializerContext` cannot be used, because there is currently no way to compose a user-supplied context with the client's internal serializer options.