One of my biggest complaints about .NET is that there's no way to guarantee a string match a certain type in the type-system.
Note
This started as more of a proof-of-concept than a real usable system, but I'm curious about feasibility in real-world work now, because it does seem moderately usable.
That is, say I want an alpha-numeric string, or I want it to be no longer than a certain length, I have no guarantee that the string passed to a function will meet those requirements. I have to run my validation each and every time I call a function that needs that validity.
This problem is a tough problem to correct, especially as string
is sealed
. Because we cannot inherit from a string
, we have to build our own implementation.
As a result, I built a simple implementation that seems to work properly, but I'm curious on any intricacies I might have missed.
I tried to make sensible decisions for the case when certain things are null, but I'm curious on any other suggestions anyone might have for other situations that have been missed.
It starts with the ValidatedString
abstract class:
[JsonConverter(typeof(ValidatedStringJsonNetConverter))]
public abstract class ValidatedString
: IComparable, IEnumerable, IEnumerable<char>, IComparable<string>, IComparable<ValidatedString>, IEquatable<string>, IEquatable<ValidatedString>, IXmlSerializable
{
protected abstract string ErrorRequirement { get; }
protected Exception Exception => new ArgumentException($"The value must {ErrorRequirement}");
public string String { get; private set; }
public int Length => String.Length;
public char this[int index] => String[index];
protected ValidatedString() { }
public ValidatedString(string str)
{
String = Validate(str);
}
private string Validate(string str) => IsValid(str) ? str : throw Exception;
protected abstract bool IsValid(string str);
public static implicit operator string(ValidatedString str) => str?.String;
public override bool Equals(object obj) => (String == null && obj == null) || (String?.Equals(obj) ?? false);
public override int GetHashCode() => String?.GetHashCode() ?? 0;
public override string ToString() => String?.ToString();
int IComparable.CompareTo(object obj) => (String == null && obj == null) ? 0 : ((IComparable)String)?.CompareTo(obj) ?? 0;
IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)String)?.GetEnumerator();
public IEnumerator<char> GetEnumerator() => ((IEnumerable<char>)String?.ToCharArray()).GetEnumerator();
public int CompareTo(string other) => (String == null && other == null) ? 0 : String?.CompareTo(other) ?? other.CompareTo(String);
public int CompareTo(ValidatedString other) => (String == null && other.String == null) ? 0 : String?.CompareTo(other.String) ?? other.String.CompareTo(String);
public bool Equals(string other) => (String == null && other == null) || (String?.Equals(other) ?? false);
public bool Equals(ValidatedString other) => (String == null && other.String == null) || (String?.Equals(other.String) ?? false);
public static bool operator ==(ValidatedString a, ValidatedString b) => a.String == b.String;
public static bool operator !=(ValidatedString a, ValidatedString b) => a.String != b.String;
public static int Compare(ValidatedString strA, ValidatedString strB) => string.Compare(strA.String, strB.String);
[SecuritySafeCritical]
public static int Compare(ValidatedString strA, ValidatedString strB, StringComparison comparisonType) => string.Compare(strA.String, strB.String, comparisonType);
public static int Compare(ValidatedString strA, int indexA, ValidatedString strB, int indexB, int length) => string.Compare(strA.String, indexA, strB.String, indexB, length);
[SecuritySafeCritical]
public static int Compare(ValidatedString strA, int indexA, ValidatedString strB, int indexB, int length, StringComparison comparisonType) => string.Compare(strA.String, indexA, strB.String, indexB, length, comparisonType);
public static int CompareOrdinal(ValidatedString strA, ValidatedString strB) => string.CompareOrdinal(strA.String, strB.String);
[SecuritySafeCritical]
public static int CompareOrdinal(ValidatedString strA, int indexA, ValidatedString strB, int indexB, int length) => string.CompareOrdinal(strA.String, indexA, strB.String, indexB, length);
public static bool Equals(ValidatedString a, ValidatedString b) => string.Equals(a.String, b.String);
[SecuritySafeCritical]
public static bool Equals(ValidatedString a, ValidatedString b, StringComparison comparisonType) => string.Equals(a.String, b.String, comparisonType);
XmlSchema IXmlSerializable.GetSchema() => null;
void IXmlSerializable.ReadXml(XmlReader reader)
{
var isEmpty = reader.IsEmptyElement;
reader.Read();
if (isEmpty) return;
String = Validate(reader.Value);
}
void IXmlSerializable.WriteXml(XmlWriter writer)
{
writer.WriteValue(String);
}
}
Here, we do a lot of the major work required. This is the foundation of our string validation: we build the infrastructure for it to make sure we work consistently.
From there, it's just a matter of building an implementation. I built a second major abstract class: RegexString
, which can be supplied with a regular expression to perform the validation:
public abstract class RegexString
: ValidatedString
{
protected abstract string RegexValidation { get; }
protected abstract bool AllowNull { get; }
protected override string ErrorRequirement => $"match the Regular Expression: {RegexValidation}";
private Regex _regex;
protected RegexString() { }
public RegexString(string str) : base(str) { }
protected override bool IsValid(string str)
{
if (_regex == null) { _regex = new Regex(RegexValidation); };
if (str == null) { return AllowNull; }
return _regex.IsMatch(str);
}
}
That said, no one has to use the RegexString
: it's trivial to build other implementations, like a NonEmptyString
:
public class NonEmptyString
: ValidatedString
{
protected override string ErrorRequirement => "not be null, empty, or whitespace";
protected NonEmptyString() { }
public NonEmptyString(string str) : base(str) { }
protected override bool IsValid(string str) => !string.IsNullOrWhiteSpace(str);
public static explicit operator NonEmptyString(string str) => new NonEmptyString(str);
}
Now obviously there's a point to all of this, and I'm getting to that now.
In my situations, I often want to guarantee that certain strings, like a username
or email
, are of a certain format. Previously, to do that, I would need to add many guard-clauses at the beginning of my function to validate them all. Now, instead, I just change their type:
public class StringEmail : RegexString
{
protected override string ErrorRequirement => "be a valid email of the format <example>@<example>.<com>";
protected override string RegexValidation => @"^.+@.+\..+$";
protected override bool AllowNull => false;
protected StringEmail() { }
public StringEmail(string str) : base(str) { }
public static explicit operator StringEmail(string str) => new StringEmail(str);
}
Then I require that string type in the class:
public class Test
{
public StringEmail Email { get; set; }
}
This allows me to guarantee that the string is validated before it is given to me. Because there are no conversions, one cannot skip the validation process. Even serialization to/from XML/JSON revalidates the string. (This is why we implement IXmlSerializable
, and why we have a ValidatedStringJsonNetConverter
below.)
public class ValidatedStringJsonNetConverter : JsonConverter
{
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) =>
writer.WriteValue((value as ValidatedString).String);
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) =>
Activator.CreateInstance(objectType, reader.Value);
public override bool CanConvert(Type objectType)
{
#if NETSTANDARD_1_0
try
{
return Activator.CreateInstance(objectType) is ValidatedString;
}
catch
{
// If we can't make an instance it's definitely not our type
return false;
}
#else
return objectType.IsSubclassOf(typeof(ValidatedString)) || objectType == typeof(ValidatedString);
#endif
}
}
A few other basic implementations:
public class StringAlpha
: RegexString
{
protected override string RegexValidation => "^[a-zA-Z]*$";
protected override string ErrorRequirement => "contain only alphabetical (a-z) characters";
protected override bool AllowNull => true;
protected StringAlpha() { }
public StringAlpha(string str) : base(str) { }
public static explicit operator StringAlpha(string str) => new StringAlpha(str);
}
public class StringAlphaNum
: RegexString
{
protected override string RegexValidation => "^[a-zA-Z0-9]*$";
protected override string ErrorRequirement => "contain only alphabetical (a-z) or numeric (0-9) characters";
protected override bool AllowNull => true;
protected StringAlphaNum() { }
public StringAlphaNum(string str) : base(str) { }
public static explicit operator StringAlphaNum(string str) => new StringAlphaNum(str);
}
public class StringHex
: RegexString
{
protected override string RegexValidation => "^[0-9a-fA-F]*$";
protected override string ErrorRequirement => "be a hexadecimal number";
protected override bool AllowNull => true;
protected StringHex() { }
public StringHex(string str) : base(str) { }
public static explicit operator StringHex(string str) => new StringHex(str);
}
public class StringHexPrefix
: RegexString
{
protected override string RegexValidation => "^(0x|&H)?[0-9a-fA-F]*$";
protected override string ErrorRequirement => "be a hexadecimal number (optional 0x or &H prefix)";
protected override bool AllowNull => true;
protected StringHexPrefix() { }
public StringHexPrefix(string str) : base(str) { }
public static explicit operator StringHexPrefix(string str) => new StringHexPrefix(str);
}
public class StringNum
: RegexString
{
protected override string RegexValidation => "^[0-9]*$";
protected override string ErrorRequirement => "contain only numeric (0-9) characters";
protected override bool AllowNull => true;
protected StringNum() { }
public StringNum(string str) : base(str) { }
public static explicit operator StringNum(string str) => new StringNum(str);
}
And finally, some of the remaining base classes one could build from:
public abstract class String_N
: RegexString
{
protected abstract int MaxLength { get; }
protected override string RegexValidation => $"^.{{0,{MaxLength}}}$";
protected override string ErrorRequirement => $"be no more than {MaxLength} characters";
protected override bool AllowNull => true;
protected String_N() { }
public String_N(string str) : base(str) { }
}
public abstract class StringN_
: RegexString
{
protected abstract int MinLength { get; }
protected override string RegexValidation => $"^.{{{MinLength},}}$";
protected override string ErrorRequirement => $"be no less than {MinLength} characters";
protected override bool AllowNull => true;
protected StringN_() { }
public StringN_(string str) : base(str) { }
}
public abstract class StringNN
: RegexString
{
protected abstract int MinLength { get; }
protected abstract int MaxLength { get; }
protected override string RegexValidation => $"^.{{{MinLength},{MaxLength}}}$";
protected override string ErrorRequirement => $"be between {MinLength} and {MaxLength} characters";
protected override bool AllowNull => true;
protected StringNN() { }
public StringNN(string str) : base(str) { }
}
public abstract class StringWhitelist
: RegexString
{
private const string _special = @"[\^$.|?*+()";
protected abstract char[] Whitelist { get; }
protected override string RegexValidation => $"^[{CreateWhitelist(Whitelist)}]*$";
protected override string ErrorRequirement => $"contain only the whitelisted characters: {CreateWhitelist(Whitelist)}";
protected override bool AllowNull => true;
protected StringWhitelist() { }
public StringWhitelist(string str) : base(str) { }
public static string CreateWhitelist(char[] whitelist)
{
var result = new StringBuilder(whitelist.Length);
foreach (var c in whitelist)
{
if (_special.IndexOf(c) >= 0)
{
result.Append($@"\{c}");
}
else
{
result.Append(c);
}
}
return result.ToString();
}
}
public abstract class StringWhitelist_N
: StringWhitelist
{
protected abstract int MaxLength { get; }
protected override string RegexValidation => $"^[{CreateWhitelist(Whitelist)}]{{0,{MaxLength}}}$";
protected override string ErrorRequirement => $"be no more than {MaxLength} characters and {base.ErrorRequirement}";
protected StringWhitelist_N() { }
public StringWhitelist_N(string str) : base(str) { }
}
public abstract class StringWhitelistN_
: StringWhitelist
{
protected abstract int MinLength { get; }
protected override string RegexValidation => $"^[{CreateWhitelist(Whitelist)}]{{{MinLength},}}$";
protected override string ErrorRequirement => $"be no less than {MinLength} characters and {base.ErrorRequirement}";
protected StringWhitelistN_() { }
public StringWhitelistN_(string str) : base(str) { }
}
public abstract class StringWhitelistNN
: StringWhitelist
{
protected abstract int MinLength { get; }
protected abstract int MaxLength { get; }
protected override string RegexValidation => $"^[{StringWhitelist.CreateWhitelist(Whitelist)}]{{{MinLength},{MaxLength}}}$";
protected override string ErrorRequirement => $"be between {MinLength} and {MaxLength} characters and {base.ErrorRequirement}";
protected StringWhitelistNN() { }
public StringWhitelistNN(string str) : base(str) { }
}
Another note: when using Newtonsoft.Json.JsonConvert
or System.Xml.Serialization.XmlSerializer
, this serializes directly to/from the raw node, this doesn't serialize the class, but strictly the string:
var xmlSer = new XmlSerializer(test.GetType()); byte[] buffer; using (var ms = new System.IO.MemoryStream()) { xmlSer.Serialize(ms, test); buffer = ms.GetBuffer(); } Console.WriteLine(new UTF8Encoding(false).GetString(buffer)); using (var ms = new System.IO.MemoryStream(buffer)) { var result = (Test)xmlSer.Deserialize(ms); Console.WriteLine(result.Email); } var jsonResult = JsonConvert.SerializeObject(test); Console.WriteLine(jsonResult); Console.WriteLine(JsonConvert.DeserializeObject<Test>(jsonResult).Email);
Result:
<?xml version="1.0"?> <Test xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <Email>[email protected]</Email> </Test> [email protected] {"Email":"[email protected]"} [email protected]
Any commentary is welcome, but especially any commentary with regard to whether this might be safe or not to use.
And finally, if you want to see it on GitHub: EBrown8534/Evbpc.Strings
trim
because that can affect the validation. Instead, you would want towhatever.String.Trim()
or what-have-you, because it's possible that people would validate against whitespace, and I don't want to negatively impact that idea. \$\endgroup\$