From C# to TypeScript: Tackling nullability challenges in model generation

TL;DR When automatically generating TypeScript models from ASP.NET Core models, you may encounter differences in nullability handling. TypeScript properties are nullable and undefined, while C# properties are non-nullable. To overcome this challenge, enable support for nullable reference types in Swagger and use use custom Schema Filter to mark non-nullable properties as required.

Problems with generated models and nullability

Imagine we have this API endpoint to save an article.

[HttpPost(Name = "SaveArticle")]
public IActionResult SaveArticle([FromBody] SaveArticleRequest request) {
    // (...)
}

It accepts the following request (the nullable reference types are enabled in the project):

public class SaveArticleRequest
{
    public required string Title { get; set; }
    public DateTime Date { get; set; }
    public IEnumerable<string>? Tags { get; set; }
}

Let's generate TypeScript models using an NPM package openapi-ts:

export type SaveArticleRequest = {
    title?: string | null;
    date?: string;
    tags?: Array<(string)> | null;
};

It's not what we expected!

  • Why title is non-nullable, but possibly undefined or null?
  • Why date is non-nullable, but possibly undefined?

The generated TS model is based on the swagger.json file which is a JSON representation of the OpenAPI specification for our API. Let's see:

{
    "SaveArticleRequest": {
        "required": [],
        "type": "object",
        "properties": {
          "title": {
            "type": "string",
            "nullable": true
          },
          "date": {
            "type": "string",
            "format": "date-time"
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "nullable": true
          }
        }
    }
}

We're interested in nullable and required attributes.

  • If in schema the property is nullable, it will be typed as null.
  • If in schema the property is not required, it will be typed as undefined.

In OpenAPI specification, the required and nullable attributes are mutually exclusive.

  • The required keyword indicates that a property must be provided in a request body.
  • The nullable property means it accepts null as valid value.

A property can be either nullable or required, but not both at the same time. If property is required, it means it must have a non-null value.

Problems:

  • Why non-nullable porperties are nullable in the JSON schema file? We need to enable support for nullable reference types in Swagger.
  • Why no property is present in the required array? We need to mark non-nullable properties as required.

Enable support for nullable reference types in Swagger

Let's enable the detection of non-nullable reference types in Swagger:

builder.Services.AddSwaggerGen(options =>
{
    // Enable the detection of non-nullable reference types 
    // so that the nullable flag is set correctly in swagger.json.
    options.SupportNonNullableReferenceTypes();

    // Use the 'allOf' keyword to extend reference schemas.
    // Fixes issues with representing nullable Enum types.
    // See: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/2378
    options.UseAllOfToExtendReferenceSchemas();
}); 

Let's generate the TS model again:

export type SaveArticleRequest = {
    title?: string;
    date?: string;
    tags?: Array<(string)> | null;
};

Non-nullable properties are no longer typed as null, but they're still undefined. Let's fix that!

Mark non-nullable properties as required

Since a required property must have a non-null value and a nullable property can be null, it doesn't make sense for a property to be both required and nullable at the same time. So if property isn't explicitly declared as nullable, let's assume it's required.

We can either:

  • Use [Required] attribute from Data Annotations, or
  • Use ISchemaFilter to add non-nullable properties to the required array in schema.

I prefer NOT to use the [Required] attribute for two reasons:

  1. Applying it to almost every property would lead to codebase clutter.
  2. It requires an additional package (System.ComponentModel.DataAnnotations).

Let's create Schema Filter then!

builder.Services.AddSwaggerGen(options =>
{
    // (...)

    // Mark non-nullable properties as required.
    options.SchemaFilter<RequireNonNullablePropertiesSchemaFilter>();
}); 

public class RequireNonNullablePropertiesSchemaFilter : ISchemaFilter
{
    public void Apply(OpenApiSchema schema, SchemaFilterContext context)
    {
        if (schema.Properties is null) return;

        var nonNullableProperties = schema.Properties
            .Where(x => !x.Value.Nullable)
            .Select(x => x.Key);

        // If property isn't explicitly declared as nullable, it is assumed to be required.
        foreach (var property in nonNullableProperties)
        {
            schema.Required.Add(property);
        }
    }
}

With support for nullable reference types enabled, we can rely on the OpenAPI schema itself to determine if property is nullable (without using reflection).

The Apply method finds all properties with Nullable set to false and include them in the list of required objects.

Let's generate the TS model again:

export type SaveArticleRequest = {
    title: string;
    date: string;
    tags?: Array<(string)> | null;
};

Great! Non-nullable properties are neither undefined nor null.

Resources