Skip to content

Numeric Enumerations

recipe JSON Schema C# numeric enumerations enum oneOf $ref $defs const EvaluateSchema Match ConstInstance TryGetValue

This recipe demonstrates how to use JSON Schema enum keyword with numeric values to create type-safe numeric enumerations.

The Pattern

While string enumerations are common, sometimes you need numeric enumerations - perhaps for:

  • Integration with existing systems that use numeric codes
  • Compact wire format
  • Bitwise operations

JSON Schema supports numeric enum values just like string values.

Using oneOf with const to create documented numeric enumerations provides several advantages over a simple enum array ({"enum": [1, 2, 3]}), which loses documentation context. The oneOf + const pattern provides:

  • Named definitions for each value
  • Title and description for each option
  • Better IDE support and documentation generation

The Schema

File: status.json

{
  "oneOf": [
    { "$ref": "#/$defs/Pending" },
    { "$ref": "#/$defs/Active" },
    { "$ref": "#/$defs/Complete" }
  ],

  "$defs": {
    "Pending": {
      "title": "Pending status",
      "description": "The operation is waiting to start",
      "const": 1
    },
    "Active": {
      "title": "Active status",  
      "description": "The operation is currently running",
      "const": 2
    },
    "Complete": {
      "title": "Complete status",
      "description": "The operation has finished",
      "const": 3
    }
  }
}

This creates a numeric enumeration with three documented values. Each const defines a specific numeric value (1, 2, or 3), and the title and description provide context that will appear in generated documentation.

Generated Code Usage

Using static const instances

The generator creates a static const instance for each oneOf variant:

// Use predefined const instances - zero allocation
Status pending = Status.Pending.ConstInstance;      // value: 1
Status active = Status.Active.ConstInstance;        // value: 2
Status complete = Status.Complete.ConstInstance;    // value: 3

Console.WriteLine($"Active status: {active}");
// Output: Active status: 2

Benefits of const instances:

  • Zero allocation - reuses the same immutable instance
  • Type-safe - compile-time correctness prevents invalid values
  • Self-documenting - named instances (Pending, Active, Complete) instead of magic numbers
  • Performance - no parsing overhead

Parsing numeric enumeration values

You can also parse from JSON:

string json = "1";
using var parsed = ParsedJsonDocument<Status>.Parse(json);
Status status = parsed.RootElement;
Console.WriteLine($"Status: {status}");
// Output: Status: 1

Extracting numeric values

// Get the underlying numeric value
if (status.TryGetValue(out int value))
{
    Console.WriteLine($"Numeric value: {value}");
    // Output: Numeric value: 1
}

Validating enumeration values

You can use EvaluateSchema() to check whether a parsed value matches one of the defined enum constants:

using var invalidDoc = ParsedJsonDocument<Status>.Parse("99");
Status invalid = invalidDoc.RootElement;
Console.WriteLine($"Status {invalid} is valid: {invalid.EvaluateSchema()}");
// Output: Status 99 is valid: False

Pattern matching with documented variants

With the oneOf + const pattern, you get named pattern matching based on the variant types:

string DescribeStatus(in Status status)
{
    return status.Match(
        matchPending: static (in Status.Pending _) => "Pending - waiting to start",
        matchActive: static (in Status.Active _) => "Active - currently running",
        matchComplete: static (in Status.Complete _) => "Complete - finished",
        defaultMatch: static (in Status _) => "Unknown status");
}

The match parameters use the names from your $defs (Pending, Active, Complete), making the code self-documenting. Each variant is a distinct type, providing full type safety.

Pattern matching with context

You can pass additional state through the match:

string ProcessStatus(in Status status, int requestCount)
{
    return status.Match(
        requestCount,  // context parameter
        matchPending: static (in Status.Pending _, in int count) => 
            $"Queued {count} requests - system pending",
        matchActive: static (in Status.Active _, in int count) => 
            $"Processing {count} requests on active system",
        matchComplete: static (in Status.Complete _, in int count) => 
            $"Cannot process {count} requests - system complete",
        defaultMatch: static (in Status _, in int count) => 
            throw new InvalidOperationException($"Unknown status cannot process {count} requests"));
}

Why Use oneOf + const Instead of enum?

The simple enum approach ({"enum": [1, 2, 3]}) works but has limitations:

{
    "enum": [1, 2, 3]
}

Limitations:

  • No documentation for each value
  • Generic generated names (EnumValues.NumberOne, NumberTwo, NumberThree)
  • No semantic meaning attached to the numbers

The oneOf + const pattern shown in this recipe provides:

  • Meaningful names for each numeric value (Pending, Active, Complete)
  • Rich documentation via title and description
  • Separate types for each value (Status.Pending, Status.Active, Status.Complete)
  • Type-safe pattern matching with named parameters
  • Prevents implicit numeric conversions, improving type safety

For more details on this pattern, see the blog post.

Key Differences from V4

The oneOf + const pattern and its Match() API are essentially the same between V4 and V5. Both generate variant types with ConstInstance properties and exhaustive pattern matching.

V4 (Corvus.Json)

// Access constant instances
Status pending = Status.Pending.ConstInstance;

// Pattern matching
string desc = status.Match(
    matchPending: static (in Status.Pending _) => "Pending",
    matchActive: static (in Status.Active _) => "Active",
    matchComplete: static (in Status.Complete _) => "Complete",
    defaultMatch: static (in Status _) => "Unknown");

V5 (Corvus.Text.Json)

// Access constant instances (same pattern)
Status pending = Status.Pending.ConstInstance;

// Pattern matching (same pattern)
string desc = status.Match(
    matchPending: static (in Status.Pending _) => "Pending",
    matchActive: static (in Status.Active _) => "Active",
    matchComplete: static (in Status.Complete _) => "Complete",
    defaultMatch: static (in Status _) => "Unknown");

Key differences:

  • The Match() API (including the context parameter overload), ConstInstance properties, and exhaustive handling work the same way in both versions
  • V5 uses ParsedJsonDocument<T> for parsing from external JSON input

Running the Example

cd docs/ExampleRecipes/015-NumericEnumerations
dotnet run

Frequently Asked Questions

Q: Why use oneOf + const instead of a simple enum array?

A: The oneOf + const pattern gives each numeric value a name, a title, and a description in the schema. This produces named constant instances (e.g., Status.Pending.ConstInstance) and descriptive match handler parameters in the generated code. A simple enum array like [0, 1, 2] provides no documentation or named access.

Q: Can I combine numeric and string enumerations?

A: Each oneOf variant can use any const type, so you could technically mix them. In practice, it's better to keep enumerations homogeneous — use numeric values for status codes and bitfields, and string values for human-readable identifiers. Mixing types makes pattern matching and TryGetValue() extraction more complex.

Q: How do I add a new enum value without breaking existing code?

A: Add a new entry to the oneOf array in your schema and regenerate the types. The generated Match() method will gain a new handler parameter, causing a compile error in every call site that hasn't been updated. This is intentional — it ensures you handle the new value everywhere rather than silently ignoring it.

Q: Are ConstInstance values allocated on the heap?

A: No. ConstInstance properties return a struct-based JSON element backed by a static, pre-parsed byte buffer. There is no heap allocation when accessing them. This makes them ideal for comparisons and for constructing new instances from known values without any runtime parsing cost.