Skip to content

Thinking Functionally: Function Signatures

Paul Louth edited this page May 20, 2017 · 13 revisions

Declarative functions signatures are just good practice whether you're writing imperatively or functionally. On any suitably large project - with multiple developers - it's impossible to know everything that goes on within a function (and you shouldn't have to know). Documentation can only take you so far, and tends to rot over time. So we need the signature of the function to tell us what's what.

For example:

    Date CreateDate(int a, int b, int c);

Clearly that's not as declarative as:

    Date CreateDate(int year, int month, int day);

We're all used to using names to communicate with other devs about our intentions. Names are a pretty crude tool for constraining data. The compiler won't do anything if you provide a month with the number 13. Ideally we need this:

    Date CreateDate(Year year, Month month, Day day);

But creating a new-type for every usage of an int (or any unconstrained base-type) would be extremely tedious.

Constrained types

Language-ext provides three classes to help: NewType, NumType, FloatType. NewType is the most general case, NumType would be used for integer number types (int, short, long, BigInteger, etc.), and FloatType is used for floating point number types (float, double, decimal, etc.).

So let's create Year, Month, Day:

    public class Year : NumType<Year, TInt, int>
    {
        Year(int x) : base(x) { }
    }

    public class Month : NumType<Month, TInt, int>
    {
        Month(int x) : base(x) { }
    }

    public class Day : NumType<Day, TInt, int>
    {
        Day(int x) : base(x) { }
    }

We can now create typed values:

    var day = Day.New(1);
    var month = Month.New(1);
    var year = Year.New(2000);

But you can't assign a Day to a Month, or a Month to a Year, because they're not the same types.

What about constraints. We can just use constructors:

    public class Year : NumType<Year, TInt, int>
    {
        Year(int x) : base(x) 
        { 
           if(x < 1970 || x > 2050) throw new ArgumentException("Invalid year");
        }
    }

    public class Month : NumType<Month, TInt, int>
    {
        Month(int x) : base(x)
        { 
           if(x < 1 || x > 12) throw new ArgumentException("Invalid month");
        }
    }

    public class Day : NumType<Day, TInt, int>
    {
        Day(int x) : base(x)
        { 
           if(x < 1 || x > 31) throw new ArgumentException("Invalid day");
        }
    }

Or we can do it declaratively:

    using LanguageExt.ClassInstances;
    using LanguageExt.ClassInstances.Const;
    using LanguageExt.ClassInstances.Pred;

    public class Year : NumType<Year, TInt, int, Range<TInt, int, I1970, I2050>>
    {
        Year(int x) : base(x) { }
    }

    public class Month : NumType<Month, TInt, int, Range<TInt, int, I1, I12>>
    {
        Month(int x) : base(x) { }
    }

    public class Day : NumType<Day, TInt, int, Range<TInt, int, I1, I31>>
    {
        Day(int x) : base(x) { }
    }

It's a little awkward to use the declarative style, so I would understand if you prefer the constructor approach. To see what predicates are available for validation check the: LanguageExt.ClassInstances.Pred namespace and the constants are in LanguageExt.ClassInstances.Const. You can build your own predicates by deriving from Pred<A> and your cown constants by deriving from Const<A>.

The benefits however are:

  • You can create re-usable validators
  • When you find a type in your source code and select 'Go To Definition' you can see the predicate that's being used. No need to look at the source

The end result is a function that looks like this:

    Date CreateDate(Year year, Month month, Day day);

And that communicates so much to the consumer of that function it's hard to go wrong, but even if you do, the type system picks it up.

You may have noticed that you could have a date which is 31st Feb. This doesn't deal with composite validation, so you still need to do that.

There are other areas these predicates in the generics can be used. For example:

    int DoSomethingWithAList(Lst<int> list)
    {
        if(list.IsEmpty) throw new ArgumentException("List must not be empty");
        ...
    }

The range on a list is another example where we can't tell from the signature what's going on.

Try this:

    int DoSomethingWithAList(Lst<NonEmpty, int> list)
    {
        // List can't ever be empty here
    }

The predicate enforces the rule and it's embedded in the type. So wherever you work with a list the NonEmpty attribute is following it around. The type system is communicating with you.

You could also do:

    int DoSomethingWithAList(Lst<MaxCount<I100>, int> list)
    {
        // List must have 100 or fewer items
    }

    int DoSomethingWithAList(Lst<CountRange<I1, I100>, int> list)
    {
        // List must have 1 - 100 items.
    }

By the time you get to a function that takes a list or something like that, you could be many methods and stack frames away from the source of the error. By using declarative types then you catch the issues and declare your intent when you create the list.

Signatures that talk

TBC