abp icon indicating copy to clipboard operation
abp copied to clipboard

Javascript proxy generation nullability issues roundup

Open hillin opened this issue 4 weeks ago • 15 comments

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³
  1. Custom structs are generated such as interface S extends any, which does not work.
  2. Not all CLR enums are tested.
  3. 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:

  1. We can see this rule is not consistent across different types. While all required T are generated as non-nullable, T?, T, and required T? all behave differently. It seems the rule - if there is one clearly defined - is very convoluted.
  2. We can argue that required is 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 to null.

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.

hillin avatar Dec 01 '25 11:12 hillin

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! 🙏

fahrigedik avatar Dec 01 '25 12:12 fahrigedik

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.

maliming avatar Dec 04 '25 11:12 maliming

Although the property allows null values, the backend needs it to be non-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;
}

IsNullable only checks if the property is Nullable<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>)?

hillin avatar Dec 04 '25 12:12 hillin

hi

Image

maliming avatar Dec 05 '25 01:12 maliming

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).

hillin avatar Dec 05 '25 03:12 hillin

Image

maliming avatar Dec 05 '25 03:12 maliming

I guess the validation system should also treat undefined and null differently? Is it possible?

hillin avatar Dec 05 '25 03:12 hillin

This is ASP.NET Core behavior.

https://learn.microsoft.com/en-us/aspnet/core/mvc/models/validation

maliming avatar Dec 05 '25 04:12 maliming

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?

hillin avatar Dec 06 '25 01:12 hillin

hi

No problem, I will add code.

Thanks.

maliming avatar Dec 07 '25 02:12 maliming

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

maliming avatar Dec 07 '25 04:12 maliming

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!

hillin avatar Dec 07 '25 13:12 hillin

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.

maliming avatar Dec 08 '25 02:12 maliming

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?

hillin avatar Dec 09 '25 00:12 hillin

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
}
Image

maliming avatar Dec 09 '25 01:12 maliming