Open louthy opened 7 years ago
If I understand correctly, part of what you'd like is already available:
namespace ConsoleApplication1 {
using UserId = Int16;
using FirstName = String;
using SurName = String;
using IntMapToString = Dictionary<int, string>;
class Program {
static void Main(string[] args) {
}
void Test(UserId userId, FirstName firstName, SurName surName) {
// Do stuff here.
}
}
}
Using using
with generics is not possible though,
Edit: I didn't see mentioned the using
statement in the original post. Maybe that was edited later?
@sirgru That only works within a single compilation unit (a single source file). This should work intra and inter assembly.
Yes. I think there could be value in allowing it to be scoped the same way as the namespace and allowing this to compile:
using IntMap<T> = Dictionary<int, T>;
In reality it's just really annoying having to specify it for every source file, and has none of the constraints I listed. It's also a major maintenance headache if you want to change the type being aliased. I think most people who use using x = y
are using it to avoid naming clashes, not as a more declarative type system.
This proposal adds on #259, improving it in a way that would be extremely helpful for me.
Whatever we do here, I hope we don't block a future CLR improvement where I can declare a runtime type alias, enabling me to change the name of a type without breaking source or binary compatibility.
@jnm2 What does this add to #259? I see these as being the same. What am I missing?
My preoccupations is they resembles too much C typedef, then when I'll use another library I find an IntMap<T>
and how I should know that is a Dictionary with another name?
Calling and int "Year" seems yet more a typedef without any real value something as C "size_t" that in reality is simply an it :-( It will be different if "Day" while being an int (with all operators working!) will have the added value of the validation (only a positive number between 1 and 31).
I think in addition to the desires of #259 proposal, this proposal brings a couple of functional programming habits into C#. For example, the desire to clearly carve out the limitations of a type and carry them explicitly in the type definition.
IMHO this does not work in C# because all users have to construct that specific limited type for every argument of the methods they are calling. This brings a lot of extra work on the client's side and "over-specification" that does not necessarily bring further clarity. They aim to bring the "documentation of limitations with the type" but it ends up being a burden to bring it everywhere. The classic, dead-simple way to do it, check arguments and bail as soon as they are invalid:
class DateTime {
public DateTime(int year, int month, int day) {
if(!IsValidDate(month, day)) throw new System.ArgumentOutOfRangeException("Unexpected value range for month / day.");
// Do work
}
private bool IsValidDate(int month, int day) {
// Do work
return true;
}
}
class User {
void Operation() {
var date = new DateTime(year: 2017, month: 4, day: 6);
}
}
To me, this is 10x clearer because:
1) The DateTime(year: 2017, month: 4, day: 6);
will always avoid the perceived error of mixing the arguments.
2) Throwing exceptions is the preferred signaling method that something unexpected is happening, with arguments and otherwise. As proposed, the exception is still thrown but the throwing point is in the predicate and not at the natural spot of checking for validity. This could bring less clarity when debugging.
3) In C#, this does not guard us from doing anything wrong, as mentioned:
var y = new List<NonEmpty<int>, int>(new int [0]); // Compiles, but throws at run-time
so, the only purpose of carrying these properties within the type is to carry the documentation with the definition. The exception will be thrown at the call site either way, and no further compile time safety is gained.
If there is a lot of these constraints on a type, then in a general case some other abstraction can be made in an OOP way and the checks can be done there.
I think this proposal sees the bulky-ness of the solutions, and tries to create additional language features to combat that.
My preoccupations is they resembles too much C typedef, then when I'll use another library I find an IntMap
and how I should know that is a Dictionary with another name?
Tooling can help here. A tooltip that shows the alias when you hover over it would solve that issue. F12ing to the definition seems trivial too. This is an issue with all polymorphic types, so I'm not convinced it's a negative against this proposal.
Calling and int "Year" seems yet more a typedef without any real value something as C "size_t" that in reality is simply an int :-(
It isn't simply an int
, it's a Year
that happens to be backed by an int
. It stops programmers accidentally providing a Day
where the Year
should be, and forces them to be explicit when providing an int
.
It will be different if "Day" while being an int (with all operators working!) will have the added value of the validation (only a positive number between 1 and 31).
I agree, see my comment on the Constrained Types proposal. Extending this to contain a predicate of some sort would be great, but I already know there's value to strongly typing simple types as I'm using it with the NewType
feature I talk about in that comment. It's also great for Func
signatures to describe exactly what the expected parameters are.
Func<Year, Month, Day, DateTime>
I think a constrained type system should be part of a general type system improvement, rather than something specifically for alias types. But I'd be happy to flesh that out if people thought it would be more appropriate here? (EDIT: Now fleshed out in the original issue)
To me, this is 10x clearer because:
You're taking one example, then using a technique that isn't enforced by the compiler and saying 'this works'. Are you saying you've never been confused over a signature that takes ints, strings, bools, etc.? Have you never had an int
input variable get accidentally assigned to the wrong thing mid-method and because there's no compiler errors the value has propagated and caused issues later?
so, the only purpose of carrying these properties within the type is to carry the documentation with the definition.
No, it's to enforce type-safety for the life of the object. The self documenting declarative nature of it is a very, very nice side-effect.
The exception will be thrown at the call site either way, and no further compile time safety is gained.
Having an exception thrown at the source of the error is a most valuable feature. Just like nullable references are walking time-bombs that need to be constantly checked, so are integer and string values, because they don't have a 'context'. The example of a NonEmptyList, you want to know at the source of the instantiation that the type isn't going to work later when it's propagated through myriad functions.
I have added some example syntax for how a possible constrained alias might work:
public alias Year = int
where value > -5000 && value < 5000;
public alias Month = int
where value >= 1 && value <= 12;
public alias Day = int
where value >= 1 && value <= 31;
public alias NonEmptyList<A> = List<A>
where value.Count > 0;
public alias Username = string
where value.Length > 0;
public alias Password = string
where value.Length > 6 &&
value.Exists(Char.IsLetter) &&
value.Exists(Char.IsDigit) &&
value.Exists(Char.IsPunctuation);
The idea would be that the where
expression is injected into the constructor for the new type, and would throw an ArgumentOutOfRange
error if the expression returns false
.
That sounds an awful lot like the contracts proposal(s).
What I was trying to say is that for the vast majority of general cases this kind of overt argument specification isn't needed, in Visual Studio there's the popup which displays the name of the next argument so the chance of such error is really low in this day and age. Where there is lack of clarity for the reader, named arguments can be used.
The non-null reference types are coming in the next versions of C#. I can understand the need to put these base types in context, but if the constraints are tied to the type then the new type can be created manually just like you've shown above. I believe most of the arguments in most APIs are user-defined types, and where the arguments are base types they can have specific constraints on the constructor, and all further work is not with base types but the "now properly constructed" type. I would find it superfluous to have a Year type whose only purpose is to be a type for the constructor at one place, I would rather have the checks inside the constructor and work with Date from there. Same general logic can apply in other cases.
Just my opinion though.
@sirgru I think you're possibly thinking a bit too narrowly about short term usage of base types. Of course we can do this validation manually every time a value is used, like we do with null
reference checking. But I'm thinking more about types that have a context (like Month
), but once they're stored as an int
that information is lost; the name of a variable can't enforce the rules of the type, no matter how obvious you think it is at the point of use. A good example is the common error in javascript with zero indexed months. That's an example of where an out-by-one error could propagate.
Another example that gets away from what might seem like the trivial Year
, Month
, Day
examples is in the project I run day to day (which is a multimillion line code-base, and therefore cognitively challenging to keep in the mind constantly), we'll use aliases for Id
types for ORM classes. So for example PersonId
is aliased to an int
, CompanyId
also. That means that the common issue of int
IDs flying about doesn't end up with the wrong ID in the wrong foreign-key field. This can be especially problematic if you have a function that takes multiple IDs, and a later refactor causes the order of arguments to change.
This is common thinking in functional languages, where the types are the 'point of truth' and not relying on imperatively injected validation throughout an app. The type system validates much more at compile time rather than relying purely on runtime checks (which may be missing, and are therefore impossible for the compiler to reason about).
@scottdorman #259 is explicitly asking for aliases internal to an assembly; my only need is for aliases that act externally.
One major problem with this proposal is that sometimes type predicates are dependent on others, e.g. Day
should be dependent on Month
and Year
in the example given in this proposal.
A more common example is Index
:
public alias NonNegative = int where value >= 0;
public alias Index<T>(ICollection<T> collection) = NonNegative
where value <= collection.Count;
void Foo(IList<T> list, Index(list) index) { ... }
Where you want it to use the collection Count
rather than just being non-negative.
I really like this feature but it could be achieved by Source Code Generator as I listed in Code Generator Catalog.
On the other hand, if you want constraints on types, "defaultability" checking might be also needed.
@ufcpp Source generators don't help when what you're doing is using type aliases to rename an existing type without breaking binary compatibility of assemblies built against that type.
@DerpMcDerp
One major problem with this proposal is that sometimes type predicates are dependent on others, e.g. Day should be dependent on Month and Year in the example given in this proposal.
That isn't a problem with this proposal, it's a problem with the example in this proposal. What you're suggesting is a much more complicated proposal which is more akin to dependently types languages. I'm not saying that's not desired (I'd love it), but it's out of the scope of this relatively simple proposal.
Overall I think this would make code much easier to understand and like how it introduces some additional type safety. I see the value of creating new "primitive" types with built-in validation that match the real-world domain. Although you can validate method parameters and throw before stuffing invalid data into a string or int we lose meaning. EmailAddress is easier to grok than string. Custom primitives help solve the "primitive obsession" code smell. Func and Action delegates are easier to understand without parameter names. Tuples are easier to understand without component names. I think there are a few cases:
(1) An alias that is meant to be EXACTLY the same as the type it aliases. It just provides a shorter more intuitive name. Implicit casting to the alias is sometimes desirable like when a method or variable expects a IntMap<T>
but you give it a Dictionary<int, T>
(2) A type that RESTRICTS the domain of the other type through its name but NOT through the values it accepts. For example if PersonId and ProductId are both represented by Guid, a method that expects a PersonId should not accept a ProductId without some kind of cast.
(3) A type that RESTRICTS that legal values of the other type like Month = int where value >= 1 && value <= 12
. This would be really useful for quickly defining specialized types with custom validation. I'm not sure where you'd draw the line between the quick syntax and having the flexibility of defining additional methods on the new type. If you had alias Farenheit = double
you might want a ConvertToCelcius
instance method. Maybe you'd want a custom constructor that normalizes a string before storing it. Some of these things could be defined with extension methods and static methods defined elsewhere, but it would be more convenient to define them as part of the alias, like...
public type Email restricts string {
static Regex _pattern = new Regex(@"^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$", RegexOptions.Compiled);
public Email(string input)
{
if (!_pattern.IsMatch(input ?? string.Empty)) throw new ArgumentOutOfRangeException();
value = input.Trim().ToLower();
}
public string UserName => { .. return portion before the domain name... }
public static (bool isValid, Email result) TryParse(string input) => { .. }
}
Hopefully it will help to solve this spaghetti: Task<Output<ICollection<IUser>>>
because currently available using syntax will look like:
using Something = System.Threading.Tasks.Task<Root.Namespace1.Output<Root.Namespace2.ICollection<Root.Namespace3.Membership.IUser>>>;
Ugly...
Type aliases are very required...
+100 for type alias'. Especially for string/int type types.
If somebody means global type aliases.. then it is important to allow them to be nested in class to narrow scope.
I think the simple aliasing (without constrains) can come first. And it should be discussed by language design team meeting as a simple but meaningful feature. Constrained types can be discussed later along with method contracts.
I agree with @gulshan, a more restricted feature for aliasing should come first. I imagine it could even avoid creating new types at all, something like this:
public newtype Email : string
public void SendEmail(Email to, IEnumerable<Email> cc, string subject, string body) { ... }
This is equivalent to:
public void SendEmail(
[NewType("Email")] string to,
[NewType("IEnumerable<Email>")] IEnumerable<string> cc,
string subject,
string body)
{ ... }
This way programs compiled by older C# versions will be able to consume the API as is, using strings, but more modern programs will get an error when they try to pass a different newtype (say, Phone) instead of Email
.
@orthoxerox With that approach, if I upgrade to a new compiler, my code suddenly stops compiling? I think that's not considered acceptable.
@svick that's what will happen if you upgrade to the compiler version supporting nullable reference types, too.
@orthoxerox It won't. Nullable reference types only cause warnings, not errors. And even then, it will be opt-in, based on the current proposal. From https://github.com/dotnet/csharplang/issues/790:
A number of the features described would lead to breaking of existing code in the form of new warnings. The simplest "solution" to this is to simply put all the warnings under a big switch. […]
@svick you got me confused there for a moment and I gave a hasty incorrect reply. There won't be a compiler error in most cases, "original type to newtype" and "newtype to original type" conversions should be implicit. The only error would be a conversion from newtype X to newtype Y, which is only possible to get when upgrading your compiler when you either use an external assembly with newtypes incorrectly (so it's a real bug that you've caught) or when you use two external assemblies and pass a newtype from one of them into another expecting a different newtype (now that's a potential issue).
I'll think about the second pitfall some more.
@orthoxerox Ok, now that I understand what you meant, that does sound fairly reasonable.
@louthy IMO, we must have a more declarative way to create primitive types with constraints to be in phase with the swagger specification data types: https://swagger.io/docs/specification/data-models/data-types/ In our side we had to develop our own primitive abstract class to manage this kind of constraints in our custom code generation.
Please see below the abstract class we have created to fulfill our needs.
public interface IPrimitiveType : IComparable
{
object Value { get; }
Type GenericType { get; }
}
public interface IPrimitiveType<T> : IPrimitiveType, IEquatable<T>, IComparable<T>
{
new T Value { get; }
}
[DataContract]
[Serializable]
[TypeDescriptionProvider(typeof(PrimitiveTypeTypeDescriptionProvider))]
[JsonConverter(typeof(PrimitiveTypeJsonConverter))]
public abstract class PrimitiveType<TMySelf, T> : IPrimitiveType<T>, IFormattable, IValidatableObject
where TMySelf : PrimitiveType<TMySelf, T>
where T : IComparable
{
static PrimitiveType()
{
var valueType = typeof(T);
if (!valueType.IsSimpleType())
{
throw new ArgumentException("The T parameter must be a simple type.");
}
if (valueType != typeof(string) && valueType != typeof(bool) && !typeof(IFormattable).IsAssignableFrom(typeof(T)))
{
throw new InvalidOperationException($"Cannot create PrimitiveType<{typeof(T).Name}> because it's not implementing {nameof(IFormattable)}.");
}
}
/// <summary>
/// Override this constructor with [JsonConstructor] attribute
/// to allow deserialisation of PrimitiveTypes with values
/// that are no longer possible throught normal constructor validation
/// </summary>
protected PrimitiveType() { }
protected PrimitiveType(T value)
{
if (value == null) throw new ArgumentNullException(nameof(value));
if ((value is Guid || value is DateTime) && value.IsNullOrDefault()) throw new ArgumentNullException(nameof(value));
Value = value;
var validationResults = Validate(null).ToArray();
if (validationResults.Length == 0) return;
if (validationResults.Length == 1) throw new ValidationException(validationResults[0].ErrorMessage);
else throw new AggregateException(validationResults.Select(r => new ValidationException(r.ErrorMessage)));
}
public virtual IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
yield break;
}
/// <summary>
/// Return the validation context display name or name of the type.
/// </summary>
protected string SayMyName(ValidationContext validationContext) => validationContext.SayMyName(this);
[NotNull]
[DataMember]
public T Value { get; private set; }
#region IPrimitiveType Members
object IPrimitiveType.Value => Value;
[SuppressMessage("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes", Justification = "F.Y.")]
Type IPrimitiveType.GenericType => typeof(T);
#endregion
public override bool Equals(object other)
{
if (other == null)
{
return false;
}
if (other is T)
{
return this.Value.Equals(other);
}
if (!(other is TMySelf))
{
return false;
}
if (this.Value == null)
{
return (other == null);
}
return this.Value.Equals(((TMySelf)other).Value);
}
public override int GetHashCode()
{
return this.Value.GetHashCode();
}
public override string ToString() => Value.ToString();
public string ToString(string format, IFormatProvider formatProvider)
{
return (Value as IFormattable)?.ToString(format, formatProvider) ?? ToString();
}
public int CompareTo(object obj)
{
var implementation = obj as TMySelf;
if (implementation != null)
{
return this.Value.CompareTo(implementation.Value);
}
else
{
return this.Value.CompareTo(obj);
}
}
public virtual bool Equals(T other)
{
return this.Equals((object)other);
}
public virtual int CompareTo(T other)
{
return this.Value.CompareTo(other);
}
public static bool operator !=(PrimitiveType<TMySelf, T> a, PrimitiveType<TMySelf, T> b)
{
return !(a == b);
}
public static bool operator ==(PrimitiveType<TMySelf, T> a, PrimitiveType<TMySelf, T> b)
{
// If both are null, or both are same instance, return true.
if (ReferenceEquals(a, b))
{
return true;
}
// If one is null, but not both, return false.
if (((object)a == null) || ((object)b == null))
{
return false;
}
return a.Equals(b);
}
public static bool operator !=(PrimitiveType<TMySelf, T> a, T b)
{
return !(a == b);
}
public static bool operator ==(PrimitiveType<TMySelf, T> a, T b)
{
// If both are null, or both are same instance, return true.
if (ReferenceEquals(a, b))
{
return true;
}
if (a == null
&& (b is Guid && ((Guid)(object)b) == default(Guid)))
{
return true;
}
// If one is null, but not both, return false.
if (((object)a == null) || ((object)b == null))
{
return false;
}
return a.Equals(b);
}
#region DO NOT USE IMPLICT OR YOU ARE GOING TO A WORLD OF PAIN!
public static explicit operator T(PrimitiveType<TMySelf, T> PrimitiveType)
{
return PrimitiveType.Value;
}
public static explicit operator PrimitiveType<TMySelf, T>(T primitive)
{
if (primitive == null || (primitive is Guid && ((Guid)(object)primitive) == default(Guid)))
{
return null;
}
var ths = (TMySelf)Activator.CreateInstance(typeof(TMySelf), primitive);
return ths;
}
#endregion
}
We use it like this:
[Serializable]
[DataContract]
public class UserId : PrimitveType<UserId, Guid>
{
public UserId(Guid value) : base(value)
{
}
}
[DataContract]
[Serializable]
public class Percent : PrimitveType<Percent, decimal>
{
public Percent(decimal value) : base(value)
{
}
public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Value < 0 || Value > 100)
yield return new ValidationResult($"{SayMyName(validationContext)} is not a valid percentage.");
}
}
As you can see we had some trouble in the json serialization, we apply the validation after the deserialization is done to avoid error management during the deserialization and response the list of errors in case of bad request.
Of course this code is incomplete, if you need more info don't hesitate to ask me.
I have run into a problem which reminded me of this issue. I am working on a library which exposed some delegate types meant to be passed by application code. The problem I found is that delegates don't seem to be compatible with methods. You can pass lambda expressions but if you want to pass a method you are out of luck. This was fixed by switching from delegates to Func and Action types. Now this is a bit awkward because either I have to explicitly define the types in each parameter or have using statements in each file. Of course I opted for using statements, but that meant I had to go through each file which needed them and add them separately. This is a case where better aliasing mechanics would be useful. Alternatively it would be nice to see some improvements to delegate type resolution and things like that.
@chrisrollins
The problem I found is that delegates don't seem to be compatible with methods. You can pass lambda expressions but if you want to pass a method you are out of luck.
What do you mean by this? Delegates can certainly be pointed to methods as long as the signatures match. Func<...>
and Action<...>
are just standard delegates.
Can you post a short repro?
@HaloFour Looks like you're right. The culprit is an implicit operator which I'm using to convert tuples to structs. It seems that the implicit operator cannot take a tuple with a method if the type it's looking for in the tuple is a delegate, but it works if I use a Func or Action type instead.
I have to say that I'm truly surprised (to say the least) by the fact that typedef equivalence is still not supported in C# in 2018(!).
Not long ago I had the need to rename an existing type to a new name without breaking client code on binary & source-code level, and coming from a C++ background, I naturally searched for ways to do that in C#, and what I found out instead is an utterly utterly broken "using alias" that nobody actually uses (pardon the pun) and everybody complains about (I don't even bother to paste the links it's everywhere).
The current status of C# "using alias" suffers from two major issues:
that basically rendered it unusable for serious purpose.
And don't start suggesting inheritence or classes... It's semantically evil to peg everything square into the OO round hole. (I don't dislike OO BTW)
And I don't really understand what the debate is all about and why the delay, it's not some rocket science langauge feature, it exists in so many mature and modern languages, and has been proven to be useful in so many scenarios. C++ even has a using that supports tempates. Why oh why..
Why oh why ...
Because every feature starts at -100 points and no one on the development team has (to date) seen enough value in typedef to champion the proposal.
Spending a few minutes thinking about the idea, I strong suspect that an implemenation of typedef would involve some fairly deep engineering in the CLR, and that there would be some nasty edge cases.
E.g. Assume you have a type Foo
and a typedef Bar
that represents that type elsewhere:
void WriteName(Foo foo) => Console.WriteLine(foo.GetType().Name);
var foo = new Foo();
var fooName = foo.GetType().Name; // Should be `Foo`
var bar = new Bar();
var barName = bar.GetType().Name; // Should be `Bar`
WriteName(foo); // What should this write to the console? Foo, probably.
WriteName(bar); // What should this write to the console? Bar? Foo? Wilbur?
The method WriteName()
is expecting a Foo
, and will expect that the type information retrieved via GetType()
will be for a Foo
. So it would be reasonable for that call to GetType()
to always return the type information for Foo
.
But ... then we have a really odd situation, where bar.GetType()
returns one set of type information in one place, and a wholly different set of type information somewhere else.
This is a very deep rabbit-hole that reveals some nasty problems pretty quickly.
Given the prevalence of code in the C# ecosystem that relies on class naming (and other conventions) to find related functionality, all of those problems would have to be resolved in useful and non-surprising ways for typedef to make it a viable feature.
@theunrepentantgeek I think what you were describing is the equivalence of "strong typedef" (i.e. the typedef creates a new type"), but what about starting with the equivalence of traditional C++ typedef? (i.e. bar.GetType() == the original type that Bar aliased from)?
@pongba For a start, convention based libraries would still fail
For example, a seralization library might expect there to be supporting classes with the suffixes Reader
and Writer
to support the serialization of a class:
public class Customer { ... }
public class CustomerReader { ... }
public class CustomerWriter { ... }
A user of that library would expect to be able to write this
public typedef Vendor : Customer
public class VendorReader { ... }
public class VendorWriter { ... }
But, those classes wouldn't be used. In fact, if the classes CustomerReader
and CustomerWriter
already existed elsewhere, they'd be used instead, with very surprising results.
Even a simple typedef where you're just defining a different name for an existing type runs into difficulties like this very quickly.
I think the key here is a fundamental difference between C++ and C#. In my (admittedly limited) understanding of C++, type names are largely or entirely elided from the compiled binary, having little or not use at runtime. This contrasts with C# where the name of a type is a fundamental thing that's used heavily at runtime for type identification, loading, and so on.
@theunrepentantgeek Thanks! That cleared things up quite a bit! :)
This will be more powerful if also allows generic aliases #3309.
Any update on this?
This is a crucial feature when it come to Domain Driven Design and modelling a domain. It's so easy in F# to assign domain specific aliases to existing types. In C#, however, it is a pain, because one has to build wrapper types which (from a domain point of view) just don't make sense. Since we have to name both the wrapper and the wrappee, we end up with having two named things in code while only one of them exists in the business domain. That is awful domain modelling whose sole reason is a technical one, namely the lack of proper global type aliases in C#.
And no, the using
directive does not solve the problem, because our domain model is used by multiple assemblies and the ubiquitous language should be used in the whole codebase.
This feature proposal should really be pushed by anyone who is serious about DDD.
Any updates on this, it'd would help with strongly typed ids a lot.
@orosbogdan Explicit extensions seems to subsume these use cases.
@orosbogdan Explicit extensions seems to subsume these use cases.
Last time implementation strategy (lowering) were mentioned it was stated that you couldn't overload on extensions; has that changed since then to allow them to?
Whatever strategy we'd go with would likely be the same as what we'd do for any strongly-typed-id scenario. :)
Whatever strategy we'd go with would likely be the same as what we'd do for any strongly-typed-id scenario. :)
.. fair enough 😅
F# has a feature called type abbreviations where a more complex type can have an easier to use alias. I am finding more and more (especially as I use structs as wrapper types to avoid null) that creating sub-classes for more bespoke behaviour is either impossible or unnecessarily heavyweight.
For further discussion is the notion of a predicate for the
value
that is run on construction and can be used to constrain the type further:The constraint would be injected into the constructor of the generated type alias (which would throw an
ArgumentOutOfRange
exception if it returnsfalse
). And could also be used be tooling to do flow analysis on whether 'bad values' were likely to get into the type.One technique that I have been using a lot recently is to embed validation into a type. Although I think this is likely to be very niche (because it's bulky to use right now), I think it opens up a ton of useful functionality that is similar to dependently typed languages.
Here's a simple example with a bespoke list implementation. This makes use of the concepts idea that has been previously investigated by the Roslyn team:
The
PRED
generic argument allows for validation behaviour to be injected into the type and the value. And therefore the compiler can do a ton of validation for free:That means
Product
can't ever get a type that represents an empty list. e.g.Clearly passing around
List<NonEmpty<int>, int>
is very annoying, and so a type-alias of:Would be great, and would make
Product
much more declarative and easy to parse:This is just one example of where this would be useful and lead to more explicit and declarative code (in my humble opinion). Obviously with the constraints it would be possible to do this:
I would prefer it to go much further than the F# version, and make the alias work more like Haskell's
newtype
. That is it's essentially not implicitly convertible with the type it's aliasing. That makes it trivial to represent lighterweight concepts like:We could then have methods like:
Which would kill another common set of bugs in C#, namely that
int
,string
, etc. are essentially 'untyped' (they have a type, but the type doesn't represent the data stored within it).Can anyone say they've never fallen over on something like this ^^^.
I think calling
new
to instantiate an aliased type is fine, but I would prefer this:To this:
We should allow explicit conversions though:
I expect under-the-hood for the compiler to create a new type for this, which should be a lightweight struct:
So
Year
would be:And then the compiler can inject the
Value
indirection and re-wrap with the single argument constructor. Obviously this comes with a run-time cost, but it is relatively small for such a powerful feature.The
AliasOf
attribute could be a hint for tooling.I'm a strong believer that types should be the guidance to the programmer rather than variable names, because variable names don't persist from the input of a function to the output, whereas types do.
(by the way I'm aware of the limitation of
new List<NonEmpty<A>, A>()
when the type is astruct
, I want to raise that as a separate issue)