TypeScript generation of Required properties has |null

I have a C# class (v9.0, nullable enabled) defined as

[Schema("Crypto")]
[DataContract]
public class Currency : AuditBase
{
    [AutoId]
    [DataMember, Required] public Guid Id { get; set; } = Guid.Empty;

    [DataMember, Required] public string Name { get; set; } = "";
    [DataMember] public string? Subname { get; set; } = null;
}

The generated TypeScript is

// @DataContract
export class Currency extends AuditBase
{
    // @DataMember
    // @Required()
    public id: string|null;            // why not string?

    // @DataMember
    // @Required()
    public name: string|null;          // why not string?

    // @DataMember
    public subname: string|null;
    
    public constructor(init?: Partial<Currency>) { super(init); (Object as any).assign(this, init); }
}

In my main Program, I use the TypeScriptGenerator.UseNullableProperties = true;

I have two questions:

  1. Why is the TS definitions of id and name have string|null instead of only string?

  2. Is there a way to customise the TypeScriptGenerator, such that “//@ts-nocheck” can inserted once at the top TS file?

I forgot to add, my MSSQL database is predefined, so not generated from the C#.:

CREATE TABLE Crypto.Currency (

    -- referential
    Id                    UNIQUEIDENTIFIER DEFAULT NEWSEQUENTIALID(),

    -- data
    Name                  NVARCHAR(60)       NOT NULL,
    Subname               NVARCHAR(60)       NULL,
    
    CONSTRAINT crypto_currency_pk PRIMARY KEY NONCLUSTERED (Id)
)

That’s the correct annotation for a nullable string property in TypeScript, string alone isn’t valid when strictNullChecks is enabled. See TypeScript Nullable properties docs for how to change what’s emitted.

It can’t be at the very top as all ServiceStack tools expects /*Options: to be on the first line, but you can emit code before the generated types with TypeScriptGenerator.InsertCodeFilter.

I used the InsertCodeFilter, as you recommended, and it worked. Thank you.

// Disable TypeScript compiler errors
TypeScriptGenerator.InsertCodeFilter =
    (List<MetadataType> allTypes, MetadataTypesConfig config) =>
        { return "// @ts-nocheck"; }; 

With regards the TS generation, I am confused, because in my C# class, the Id and Name properties are not nullable. So, I expected the TS to use string accordingly, and not string | null. In your referenced example, the Required int Value is also being defined as number | null.

So, I looked at the generated C# client code, which has no customisations:

[DataContract]
public partial class Currency : AuditBase
{
    [DataMember]
    [Required]
    public virtual Guid Id { get; set; }

    [DataMember]
    [Required]
    public virtual string Name { get; set; }

    [DataMember]
    public virtual string Subname { get; set; }   // why not string?
}
  • Is there a reason why Subname is not generated as a nullable property?
  • Is it because the generated code is not C# 9 #nullable enabled?
  • If so, can I force C# 9 to be generated?

C# 9 nullable is a compile time static analysis feature it doesn’t change the behavior at runtime.

Inside TypeScriptGenerator.PropertyTypeFilter you can control what gets emitted, you’ll need to look for the metadata heuristics emitted by the C# compiler, you can start here, you can access the PropertyInfo from prop.PropertyInfo.

To customise my TypeScript generation, I have implemented IsPropertyOptional and PropertyTypeFilter. For example:

TypeScriptGenerator.PropertyTypeFilter = (gen, type, prop) =>
    {
        if (prop.IsPrimaryKey ?? false)
        {
            return gen.GetPropertyType(prop, out var isNullable);
        }
        else if (prop.IsRequired ?? false)
        {
            return gen.GetPropertyType(prop, out var isNullable) + " | null | undefined";  // Test
        }
        else
        {
            return gen.GetPropertyType(prop, out var isNullable) + " | null";
        }
    };

For my simplified class, defined as

[Schema("Deal")]
[DataContract]
public class Deal : AuditBase
{
    [AutoId]
    [DataMember, PrimaryKey] public Guid Id { get; set; } = Guid.Empty;

    [DataMember, Required] public Guid CommodityId { get; set; } = Guid.Empty;
}

The PropertyTypeFilter is working for the PrimaryKey attribute, but not for the Required attribute, generating the following ambient declarations:

// @DataContract
interface Deal extends AuditBase
{
    // @DataMember
    // @IsPrimaryKey()
    id: string;                     // correct, not nullable for PK

    // @DataMember
    // @Required()
    commodityId?: string | null;    // incorrect - should be string | null | undefined
}

When stepping through with the debugger, I see that the prop.IsRequired value is null, whereas I expected it to be true (@Required() annotation appears in .d file).

Expanding the prop’s Attributes, I see that the RequiredAttribute is defined

Attributes: Count = 1
[0]: {ServiceStack.MetadataAttribute}
  Args: Count = 0
  Attribute: {ServiceStack.DataAnnotations.RequiredAttribute}
  ConstructorArgs:  null
  Name: "Required"

So, is my PropertyTypeFilter incorrect trying to use prop.IsRequired to identify Required properties? Is there an alternative solution?

[Required] is an OrmLite attribute it doesn’t have any impact on Services. To enforce a required property on a Request DTO you can use either the:

As the validators are only for Request DTOs, I’ve included the [Required] attribute for marking whether or not a Data Model property IsRequired, it also now marks IsRequired for #nullable enabled reference types.

This change is available from the latest v5.10.3 that’s now available on MyGet.

Perfect, works like a charm. Thank you.

Unfortunately, after more testing, it seems that prop.IsRequired is always true, even when property is not annotated with Required. Is this as expected, or am I misunderstanding the latest change?

Do you have #nullable enabled and is it not an optional string type?

C# project setting (Visual Studio 2019)

<PropertyGroup>
  <TargetFramework>net5.0</TargetFramework>
  <LangVersion>9.0</LangVersion>
  <Nullable>enable</Nullable>
</PropertyGroup>

Data model:

[DataMember, Required] public Guid StatusTypeId { get; set; } = Guid.Empty; 
[DataMember] public string? Note { get; set; } = null;

Generated typescript.d

 // @DataMember
 // @Required()
 // @IsRequired()
 // @IsValueType()
 statusTypeId: string | null | undefined;

 // @DataMember
 // @IsRequired()
 note: string | null | undefined;

In my complete customisation of the generated Typescript

// Customise TypeScript generation for client
private void ConfigureTypeScriptGenerator(Container container)
{
    // Disable TypeScript compiler errors
    TypeScriptGenerator.InsertCodeFilter =
        (List<MetadataType> allTypes, MetadataTypesConfig config) =>
            { return "// @ts-nocheck"; };

    TypeScriptGenerator.IsPropertyOptional = (gen, type, prop) =>
    {
        if (prop.IsPrimaryKey ?? false) 
        {
            return false;
        }
        else if (prop.IsRequired ?? false)
        {
            return false;
        }
        else
        {
            return true;
        }
    };

    TypeScriptGenerator.PropertyTypeFilter = (gen, type, prop) =>
        {
            if (prop.IsPrimaryKey ?? false)
            {
                return gen.GetPropertyType(prop, out var isNullable);
            }
            else if (prop.IsRequired ?? false)
            {
                return gen.GetPropertyType(prop, out var isNullable) + " | null | undefined";
            }
            else
            {
                return gen.GetPropertyType(prop, out var isNullable) + " | null";
            }
        };

    TypeScriptGenerator.PrePropertyFilter = (sb, prop, type) =>
    {
        if (prop.IsPrimaryKey ?? false)
        {
            sb.AppendLine("// @IsPrimaryKey()");
        }

        if (prop.IsRequired ?? false)
        {
            sb.AppendLine("// @IsRequired()");
        }

        if (prop.IsValueType ?? false)
        {
            sb.AppendLine("// @IsValueType()");
        }
    };
}

I did notice, in the snippets above the property (statusTypeId) with the Required annotation, is having the // @Required() in its Typescript. The property (note) without Required annotation does not have @Required() in its Typescript.

In the latest 5.10.3 on MyGet I’ve disabled inspecting #nullable enabled non-ref type heuristics by default, it can be enabled with:

SetConfig(new HostConfig {
    TreatNonNullableRefTypesAsRequired = true
});

I have tried TreatNonNullableRefTypesAsRequired = true and TreatNonNullableRefTypesAsRequired = false and I do not see any difference in the generated typescript.d

Please provide a complete C# DTO and its default generated TypeScript DTO with the issue, without any of your customizations.

I placed a minimal example project here:

The highlights are::

The complete C# DTO:

using ServiceStack.DataAnnotations;
using System;
using System.Runtime.Serialization;

namespace MinWebApp.ServiceModel.Types
{
    [Schema("Customer")]
    [DataContract]
    public class Company
    {
        [AutoId]
        [DataMember, PrimaryKey] public Guid Id { get; set; } = Guid.Empty;

        [DataMember, Required] public int Ranking { get; set; } = 0;
        [DataMember, Required] public string Name { get; set; } = string.Empty;
        [DataMember] public string? Subname { get; set; } = null;

        [DataMember, Required] public Guid CountryId { get; set; } = Guid.Empty;
        [DataMember, Required] public DateTime StatusDate { get; set; } = DateTime.UtcNow;

        [DataMember] public Guid? TurnoverCurrencyId { get; set; } = null;
        [DataMember] public decimal? TurnoverAmount { get; set; } = null;

        [DataMember] public int? EmployeeCount { get; set; } = null;

        [DataMember] public string? Note { get; set; } = null;
    }
}

The default generated typescript.d file, without any customisations:

/* Options:
Date: 2021-01-01 14:21:23
Version: 5.103
Tip: To override a DTO option, remove "//" prefix before updating
BaseUrl: https://localhost:5001

//GlobalNamespace: 
//MakePropertiesOptional: True
//AddServiceStackTypes: True
//AddResponseStatus: False
//AddImplicitVersion: 
//AddDescriptionAsComments: True
//IncludeTypes: 
//ExcludeTypes: 
//DefaultImports: 
*/


interface IReturn<T>
{
}

interface IReturnVoid
{
}

// @DataContract
interface Company
{
    // @DataMember
    id: string;

    // @DataMember
    // @Required()
    ranking: number;

    // @DataMember
    // @Required()
    name: string;

    // @DataMember
    subname: string;

    // @DataMember
    // @Required()
    countryId: string;

    // @DataMember
    // @Required()
    statusDate: string;

    // @DataMember
    turnoverCurrencyId: string;

    // @DataMember
    turnoverAmount: number;

    // @DataMember
    employeeCount: number;

    // @DataMember
    note: string;
}

// @DataContract
interface ResponseError
{
    // @DataMember(Order=1)
    errorCode: string;

    // @DataMember(Order=2)
    fieldName: string;

    // @DataMember(Order=3)
    message: string;

    // @DataMember(Order=4)
    meta: { [index: string]: string; };
}

// @DataContract
interface ResponseStatus
{
    // @DataMember(Order=1)
    errorCode: string;

    // @DataMember(Order=2)
    message: string;

    // @DataMember(Order=3)
    stackTrace: string;

    // @DataMember(Order=4)
    errors: ResponseError[];

    // @DataMember(Order=5)
    meta: { [index: string]: string; };
}

interface CompanyResponse
{
    result: Company;
    responseStatus: ResponseStatus;
}

// @Route("/company", "GET")
// @Route("/company/{Name}", "GET")
interface GetCompany extends IReturn<CompanyResponse>
{
    name: string;
}

I would expect the following generation for the interface Company:

// @DataContract
interface Company
{
    // @DataMember
    id: string;

    // @DataMember
    // @Required()
    ranking: number;

    // @DataMember
    // @Required()
    name: string;

    // @DataMember
    subname?: string | null;                  // default generation missing optional and null case

    // @DataMember
    // @Required()
    countryId: string;

    // @DataMember
    // @Required()
    statusDate: string;

    // @DataMember
    turnoverCurrencyId?: string | null;       // default generation optional and null case

    // @DataMember
    turnoverAmount?: number | null;           // default generation optional and null case

    // @DataMember
    employeeCount?: number | null;            // default generation optional and null case

    // @DataMember
    note?: string | null;                     // default generation optional and null case
}

This default behavior is expected, you still need to enable UseNullableProperties to emit nullable properties:

TypeScriptGenerator.UseNullableProperties = true;

And Config.TreatNonNullableRefTypesAsRequired to mark non-nullable ref types as required.

I tried several variations from your last response, but I could never get the prop.IsRequired() to correctly reflect the data model.

In the end, I added a helper to check the prop.Attributes directly, and this gave consistent results, matching my previous expectations for the generated TypeScript.

// Customise TypeScript generation for client
private void ConfigureTypeScriptGenerator(Container container)
{
    // Disable TypeScript compiler errors
    TypeScriptGenerator.InsertCodeFilter =
        (List<MetadataType> allTypes, MetadataTypesConfig config) =>
        { return "// @ts-nocheck"; };

    TypeScriptGenerator.IsPropertyOptional = (gen, type, prop) =>
    {
        if (prop.IsPrimaryKey ?? false)
        {
            return false;
        }
        else if (IsRequiredProp(prop))
        {
            return false;
        }
        else
        {
            return true;
        }
    };

    TypeScriptGenerator.PropertyTypeFilter = (gen, type, prop) =>
    {
        if (prop.IsPrimaryKey ?? false)
        {
            return gen.GetPropertyType(prop, out var isNullable);
        }
        else if (IsRequiredProp(prop))
        {
            return gen.GetPropertyType(prop, out var isNullable);
        }
        else
        {
            return gen.GetPropertyType(prop, out var isNullable) + " | null";
        }
    };
}

// Used because prop.IsRequired is always true
private bool IsRequiredProp(MetadataPropertyType prop)
{
    var attributes = prop.Attributes;
    return attributes?.Any(i => "Required".Equals(i.Name)) ?? false;
}

Git repo also updated.

ok thx for the repro, it highlighted a bug with Required Attribute detection. I’ve also changed back the default of TreatNonNullableRefTypesAsRequired to true and added an explicit InsertTsNoCheck option so if you enable InsertTsNoCheck and UseNullableProperties, e.g:

TypeScriptGenerator.InsertTsNoCheck = true;
TypeScriptGenerator.UseNullableProperties = true;

Your C# class:

[DataContract]
public class Company
{
    [AutoId]
    [DataMember, PrimaryKey] public Guid Id { get; set; } = Guid.Empty;

    [DataMember, Required] public int Ranking { get; set; } = 0;
    [DataMember, Required] public string Name { get; set; } = string.Empty;
    [DataMember] public string? Subname { get; set; } = null;

    [DataMember, Required] public Guid CountryId { get; set; } = Guid.Empty;
    [DataMember, Required] public DateTime StatusDate { get; set; } = DateTime.UtcNow;

    [DataMember] public Guid? TurnoverCurrencyId { get; set; } = null;
    [DataMember] public decimal? TurnoverAmount { get; set; } = null;

    [DataMember] public int? EmployeeCount { get; set; } = null;

    [DataMember] public string? Note { get; set; } = null;
}

Will generate a // @ts-nocheck at the top with this TypeScript class:

// @DataContract
export class Company
{
    // @DataMember
    public id: string|null;

    // @DataMember
    // @Required()
    public ranking: number;

    // @DataMember
    // @Required()
    public name: string;

    // @DataMember
    public subname: string|null;

    // @DataMember
    // @Required()
    public countryId: string;

    // @DataMember
    // @Required()
    public statusDate: string;

    // @DataMember
    public turnoverCurrencyId: string|null;

    // @DataMember
    public turnoverAmount: number|null;

    // @DataMember
    public employeeCount: number|null;

    // @DataMember
    public note: string|null;

    public constructor(init?: Partial<Company>) { (Object as any).assign(this, init); }
}

This change is available in the latest v5.10.3 that’s now available on MyGet.

I am glad the repo helped. The new ServiceStack version works as described, which is excellent.

I have now reduced my TS customisations to a minimum:

// Customise TypeScript generation for client
private void ConfigureTypeScriptGenerator(Container container)
{
    // Disable TypeScript compiler errors
    TypeScriptGenerator.InsertTsNoCheck = true;

    TypeScriptGenerator.UseNullableProperties = true;

    TypeScriptGenerator.IsPropertyOptional = (gen, type, prop) =>
    {
        return !(prop.IsRequired ?? false);
    };               
}

I do use TreatNonNullableRefTypesAsRequired = true, but as the name states, it is not applicable to value types, so my nullable value types still needed to be annotated as Required.

I also removed the PrimaryKey attribute, as Required was sufficient for my needs.

My generated TS is now exactly as I wanted:

// @DataContract
export class Company
{
// @DataMember
// @Required()
public id: string;

// @DataMember
// @Required()
public ranking: number;

// @DataMember
// @Required()
public name: string;

// @DataMember
public subname?: string|null;

// @DataMember
// @Required()
public countryId: string;

// @DataMember
// @Required()
public statusDate: string;

// @DataMember
public turnoverCurrencyId?: string|null;

// @DataMember
public turnoverAmount?: number|null;

// @DataMember
public employeeCount?: number|null;

// @DataMember
public note?: string|null;

public constructor(init?: Partial<Company>) { (Object as any).assign(this, init); }

}

One very happy customer, thank you for the great support, especially it being over the holiday period.

PS: Git repo updated, for the record.

1 Like