C# CSV Parsing TypeConverter Example

Sometimes, you want to apply pre-defined conversions to csv field values, such as:

  • Parsing the string in a certain way, like removing first/last character, or replacing whitespaces with underscores.
  • Have a non-nullable type in a CSV model definition, while csv can actually contain empty values, so you want to provide a default value for them.

With CsvHelper, this functionality is typically achieved using a custom TypeConverter.

Let's say we have the following CSV model mapping definition::

public readonly record struct CsvRecord {

    [Name("NAME")]
    public readonly string Instrument { get; init; }

    [Name("QTY")]
    public readonly int Quantity { get; init; }

    [Name("Profit / Loss")]
    public readonly decimal PnL { get; init; }

}

Now, we know that Quantity field sometimes contains empty values in CSV, and we would like to fill those values with zeros. To achieve this, we will subclass DefaultTypeConverter:

internal class NullableQuantityTypeConverter : DefaultTypeConverter
{
    public override object ConvertFromString(string text, IReaderRow row, MemberMapData memberMapData)
    {
        if (string.IsNullOrEmpty(text)) {
            return 0;
        }
        return int.Parse(text, NumberStyles.Integer);
    }
}

Next, we add this converter to the class map (usually put in the same file as the CsvRecord definition):

public sealed class CsvRecordMap : ClassMap<CsvRecord>
{
    public CsvRecordMap()
    {
        AutoMap(CultureInfo.InvariantCulture);
        Map(m => m.Quantity).TypeConverter(new NullableQuantityTypeConverter());
    }
}

Similarly, let's say we have a CSV field that describes some profit/loss in an accounting's notation, i.e., a values in parentheses means loss, and profit otherwise. Then we apply the following converter:

internal class PnLTypeConverter : DefaultTypeConverter
{
    public override object ConvertFromString(string text, IReaderRow row, MemberMapData memberMapData)
    {
        if (string.IsNullOrEmpty(text)) {
            return decimal.Zero;
        }
        if (text.StartsWith("(") && text.EndsWith(")")) {
            return -decimal.Parse(text[1..^1]);
        }

        return decimal.Parse(text);
    }
}

Then we add this converter again to the class map:

public sealed class CsvRecordMap : ClassMap<CsvRecord>
{
    public CsvRecordMap()
    {
        AutoMap(CultureInfo.InvariantCulture);
        Map(m => m.Quantity).TypeConverter(new NullableQuantityTypeConverter());
        Map(m => m.PnL).TypeConverter(new PnLTypeConverter());
    }
}

Finally, we need to register the class map when we read the CSV, for example:

using var csv = new CsvReader(stream, CultureInfo.InvariantCulture));
csv.Context.RegisterClassMap<CsvRecordMap>();
return csv.GetRecords<CsvRecord>().ToImmutableList();

If we need to test the same behavior in unit tests, you need to also implement ConvertToString method on your type converter subclass:

internal class PnLTypeConverter : DefaultTypeConverter
{
    ...
    public override string ConvertToString(object value, IWriterRow row, MemberMapData memberMapData) {
        if ((decimal)value < 0) {
            return "(" + base.ConvertToString(Math.Abs((decimal(value), row, memberMapData) + ")";
        }
        return base.ConvertToString(value, row, memberMapData);
    }
}