Javascript proxy generation nullability issues roundup
Over the past year, I have reported a few issues (#22132, #22798, #24303) regarding the JavaScript proxy generation system. The proxy generator is a great feature that frees us from painfully generating frontend proxy code. ABP has made many excellent design choices in building this system, but unfortunately, it's still not correctly handling the nullability of DTO properties. I apologize for spamming the issues tracker with tickets of this nature, but I have a strong instinct that these problems belong to one systematic flaw in the design, and I hope this issue can address them altogether, once and for all.
With that in mind, I have put together the following truth table:
| C# | Example Type | T? | T | required T? | required T |
|---|---|---|---|---|---|
| Built-in Value | int |
✅ number? |
❌ number |
✅ number? |
❌ number |
| Special Struct | System.Guid |
✅ string? |
✅ string? |
✅ string? |
❌ string |
| Custom Struct¹ | struct S {} |
✅ S? |
❌ S |
✅ S? |
❌ S |
| CLR Enum² | System.DayOfWeek |
✅ any? |
✅ any? |
✅ any? |
❌ any |
| Custom Enum | enum E {} |
✅ E? |
✅ E? |
✅ E? |
❌ E |
| Special Class | string |
✅ string? |
✅ string? |
❌ string |
❌ string |
| Custom Class | class C {} |
❌ C |
❌ C |
❌ C |
❌ C |
| Array | int[] |
❌ number[] |
❌ number[] |
❌ number[] |
❌ number[] |
| Dictionary | Dictionary<string, string> |
❌ Record<string, string> |
❌ Record³ |
❌ Record³ |
❌ Record³ |
- Custom structs are generated such as
interface S extends any, which does not work.- Not all CLR enums are tested.
- Shortened for conciseness.
This table shows how the nullability of a C# type is handled in the generated TypeScript code. For example, int? $\to$ number?, int $\to$ number, required int? $\to$ number?, required int $\to$ number. For clarity of reading, I added a ✅ for nullable type and ❌ for non-nullable type generated.
From this table, it is difficult to get a clear impression of how nullability works in this system. In real-world usage, it brings a lot of surprises. For instance, your C# DTO might say an int[] property is optional, but on the TypeScript side, you are forced to provide a value. If you see an optional enum property, naturally omit it, and pass that DTO to your C# backend, you might not realize that on the C# side it's actually non-nullable, resulting in a deserialization exception. For a C# required string?, you are not allowed to pass a null value on the TypeScript side. In a strictly regulated codebase, we are forced to fix all these nullability issues every time we regenerate the proxy, which makes the proxy generator much less of a pleasure to use than it should be.
Base on the discussion in this issue, it seems ABP's intention was to use the required keyword to determine nullability. However:
- We can see this rule is not consistent across different types. While all
required Tare generated as non-nullable,T?,T, andrequired T?all behave differently. It seems the rule - if there is one clearly defined - is very convoluted. - We can argue that
requiredis semantically not suitable for expressing whether a property should be nullable.required T?is totally valid: this property must be specified, but it can be set tonull.
On the other hand, it's far more natural to use .NET's nullability annotation for this job, which brings the rules down to a nice one-liner: If the property is nullable in C# (type name followed by a question mark), it is generated as nullable.
I did not explore the technical possibilities, and totally understand that there might be difficulty to retrieve the nullability information in the runtime, so I'm definitely open to ideas.
Hello @hillin , Thank you for reporting this and taking the time to share the details with us.
We’ve noted the nullability issues related to JavaScript proxy generation and will investigate them in detail. Any updates or fixes will be shared here in this issue.
Thanks again for your contribution we really appreciate it! 🙏
hi @hillin
We need to clarify a few things. For the backend application, we require model binding and validation.
[Required]
public int? Age { get; set; }
Although the property allows null values, the backend needs it to be non-null, Whether these types of property are optional depends on their DataAnnotations.
We have IsRequired in PropertyApiDescriptionModel class to show if a nullable property is required.
We will add a new IsNullable to show if a property can be null. IsNullable only checks if the property is Nullable<T>.
What do you think?
Thanks.
Although the property allows
nullvalues, the backend needs it to benon-null
@maliming I don't quite get this one, why it must be non-null?
Please check out the following example to see if I have made myself clear enough:
public class TestDto {
public required int RequiredNonNullable { get; set; }
public int OptionalNonNullable { get; set; }
public required int? RequiredNullable { get; set; }
public int? OptionalNullable { get; set; }
}
should be generated as:
export interface TestDto {
requiredNonNullable: number;
optionalNonNullable:? number;
requiredNullable: number | null;
optionalNullable?: number | null;
}
IsNullableonly checks if the property isNullable<T>.
Does this IsNullable only apply to value types that are boxed as Nullable<T>? (i.e. what about reference types such as string?, which is nullable but technically not Nullable<string>)?
hi
Shouldn't age2: null, name2: null be specified in the payload (④)?
I think null and undefined should be distinguishedly used: undefined for omitting a field (not required), while null can be used for a nullable field (either required or optional).
I guess the validation system should also treat undefined and null differently? Is it possible?
This is ASP.NET Core behavior.
https://learn.microsoft.com/en-us/aspnet/core/mvc/models/validation
Then I guess we have to comply to this behavior, but there still can be improvements to be made. In short, we only generate non-required, non-nullable fields as optional:
public class TestDto {
public required int RequiredNonNullable { get; set; }
public int OptionalNonNullable { get; set; }
public required int? RequiredNullable { get; set; }
public int? OptionalNullable { get; set; }
}
will be generated as:
export interface TestDto {
requiredNonNullable: number;
optionalNonNullable: number; // no longer optional due to being non-nullable
requiredNullable: number; // no longer optional due to being required
optionalNullable?: number | null;
}
What do you think?
hi
No problem, I will add code.
Thanks.
We have two properties. IsRequired and IsNullable
public class PropertyApiDescriptionModel
{
public bool IsRequired { get; set; } // [Required] or public required
public bool IsNullable { get; set; } // Nullable<T>
}
| IsRequired | IsNullable | Can be null in client | Must be in request | Can be null in request |
|---|---|---|---|---|
| true | true | ✅ | ✅ | ❌ |
| true | false | ❌ | ✅ | ❌ |
| false | true | ✅ | ❌ | ✅ |
| false | false | ❌ | ❌ | ❌ |
Can be null in client
Client as in, .HttpApi.Client project?
Must be in request
Must not be null in request, if I understand correctly?
public bool IsNullable { get; set; } // Nullable<T>
Just to clarify, does this include T? where T: class? (Which, again, is technically not Nullable<T>)
Otherwise it looks good enough to me!
hi
Can be null in client
This means you can set this property to null in your Angular client. Nullable<T> in backend.
Must be in request
This parameter must exist in the HTTP request (query string, form data, or body).
Can be null in request
You can set the property as null in the request.
Just to clarify, does this include T? where T: class? (Which, again, is technically not Nullable<T>)
Can you share the DTO code of the backend?
Thanks.
This means you can set this property to null in your Angular client. Nullable<T> in backend.
Do you mean:
class Dto { public required string? Value } → interface Dto { value: string | null },
so you can set Dto.value to null in the angular client, but when passing it to an API the validation will fail?
This parameter must exist in the HTTP request (query string, form data, or body).
Define exist: does value: null count as exist, or it must be a non-null value?
I think in the required + nullable case (just like the example above), because to the validator required ≡ non-null, the field cannot be null in the client (which should be generated as interface Dto { value: string }).
Just to clarify, does this include T? where T: class? (Which, again, is technically not Nullable)
Can you share the DTO code of the backend?
class Dto2 {
public string? StringValue { get; set; }
public int? IntValue { get; set; }
}
Do both StringValue and IntValue have IsNullable = true in the corresponding PropertyApiDescriptionModel?
hi
class Dto { public required string? Value } → interface Dto { value: string | null }, so you can set Dto.value to null in the angular client, but when passing it to an API the validation will fail?
You can set Value to null in the request info.
The image below shows the DTO on the backend and the data in the request.
Name6 and Name7 will use the default value: null.
Age and Age7 will use the default value: 0. Age6 is null
{
"Name": "test1",
"Name2": "test2",
"Name3": "test3",
"Name4": "test4",
"Name5": "test5",
"Name6": null,
"Name7": null,
"Age": 0,
"Age2": 2,
"Age3": 3,
"Age4": 4,
"Age5": 5,
"Age6": 0,
"Age7": null
}