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 possiblyundefined
ornull
? - Why
date
is non-nullable, but possiblyundefined
?
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 acceptsnull
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 therequired
array in schema.
I prefer NOT to use the [Required]
attribute for two reasons:
- Applying it to almost every property would lead to codebase clutter.
- 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
.