Type All the Things
This article was posted as part of F# Advent 2018. Since originally publishing, release 0.4.0 adds SoomeDependentType for working with option types. See Tutorial for usage.
In computer science and logic, a dependent type is a type whose definition depends on a value. A "pair of integers" is a type. A "pair of integers where the second is greater than the first" is a dependent type...
This is a powerful idea that strongly-typed languages like F# can apply in the service of correctness. In this article I will
- introduce dependent types for F#
- discuss advantages in domain-driven design and development
- review performance benchmarks
- and finally, discuss how these are dependent types according to type theory
(Readers with strong opinions about dependently typed languages may want to read the final section first.)
Introducing Dependent Types in F#
There are several methods for creating ad hoc granular types, you may have used some of these.
DependentTypes are easy to instantiate and carry their semantic information with them.
We see from the tool tip data elements of this type are validated by a typed function in the PercentType module
named PercentValidator, and that function has the signature unit -> float -> float option
.
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: |
|
By inheriting from the Pi
type any total function may be used to construct dependent types. (Organizing the type and function
in a module is a housekeeping convenience.)
Pi
is a function that takes an element of a type to an element of another type. That is the essence of Dependent Types.
But why is unit
necessary in the signature? Actually it is not an integral part of what the Pi function needs to be. unit
in this case is a placeholder for
a convenience feature. You can replace it with any type whatsoever to leverage the same function over similar DepenedentTypes.
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: |
|
Notice this function reuse requires a second level of type inheritance.
If the Pi function results in an option, and you use TryCreate
rather than Create
to instantiate an element the resulting
option is lifted 1 to the resulting DependentType.
Dependent Typing All the Things
If it is important enough to validate data, why not type the validated data as we did above?
Option types are the mark of validated data. Some more examples available in the DomainLib include
But DependentTypes are for more than data validation. We are not restricted to emitting option types. Anything you can do with a total function you can type.
For instance ensure that DateTime is in UTC format.
1:
|
|
We can also categorize data with discriminated union types.
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: |
|
DependentPairs
Another feature of Type Theory is the idea of Dependent Pairs. This is a typed pair of the original data element and the resulting dependently typed element.
They are just as easy to create, relying on a Sigma
type in place of the Pi
type, and of course carry around their semantic information.
Future Directions
One thing DependentTypes cannot do, yet, is support extension members. Hopefully the implementation of these F# language suggestions will remedy this situation. 2
DependentTypes is still a work in progress. Please submit suggestions and feedback.
And my apologies to anyone who has suffered through a breaking change in the project. Hopefully we are at an end of those.
Benchmarking 3
Fine. But what about performance?
Let's benchmark...
DependentType option
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: |
|
- Compare creating a percent DependentType option to creating a simple float option using the same validation logic.
- Each benchmark instance creates 1 Some option and 1 None option in both cases.
- Benchmark 1,000,000 instances, hence 2M option instances.
1: 2: |
|
Not surprisingly validation and creation of a simple option is faster, 28X faster.
And it scales nearly linearly, as we see when executing the benchmark 10X instead of 1MX.
1: 2: |
|
Considering our DependentType option benchmark creates 2M option instances in less than 700 ms, and creates 20 in 9 micro-seconds, this is probably acceptable performance for all but the most demanding network applications.
The validation logic adds little overhead. Even comparing creating DependentType to "naked" options (not run through the validation logic) makes little difference in the performance ratio.
1: 2: |
|
Can we squeeze even more performance from DependentType option creation? Let's use Create
instead of TryCreate
so we eliminate the overhead of "lifting" the 'T2
option result
to the DependentType element.
1: 2: |
|
Validate option is now only 15X faster, so the "lift" overhead of DependentType is noticeable at large scales (2M creations).
We expect from these results Create DependentType option is twice as fast as TryCreate, because it does not lift the option from the value to the DependentType. And we see if we do a Create to TryCreate direct comparison, that is roughly true.
1: 2: |
|
Consuming (reading) DependentType
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: |
|
1: 2: |
|
- Reading 2M float options is only 33% faster than read/extract value operation on DependentTypes.
Substituting the verbose Some pct.Value.Value
for the helper function someValue
almost erases any advantage of float option.
1: 2: |
|
UtcDateTime DependentType
Benchmarking a type that is not an option
, we compare UtcDateTime in the DomainLib to an implementation validating a DateTime.
This time 1M benchmark runs is also 1M instances.
1: 2: |
|
1: 2: |
|
In this case the create and read performance differences are barely meaningful.
DependentPair
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: |
|
Benchmarks comparing a validated option pair to DependentPair yields similar performance ratios to option DependentType.
1M runs again creates 2M instances.
1: 2: |
|
1: 2: |
|
Creation of a simple validated tuple is 7X faster than creating a DependentPair, but read/consume performance is so similar we sometimes see the benchmark test failing because DependentPair performs faster.
But are these Dependent Types?
Yes, from the standpoint of Type Theory 4, no, if you believe Dependent Types can only exist in languages implementing formal proof architectures.
If F# were a so-called dependently typed language, this project would be called Refinement Types, because something called dependent types would already exist in the language, and rather than being defined by a Pi type function, dependent types would depend on inductive proofs. But F# is not now and never will be an inductively proven language. Try F* for that. 5
This is the maths notation for dependent types in type theory.
∏(x:A) B(x)
It tells us that ∏ (Pi) is a function that takes an element of Type A and sends it to a family of Types, B. In other words, the resulting type (and its element value) depend on the input element value.
You see, Type Theory defines dependent types without regard to any proof. A dependent type is what it is by construction.
The F# type system does not allow for a family of types, but there are types that we can use to mimic a family of types.
- Any F# or .NET type, a family of one type.
- An F# option type, mimicking a family of two types.
- An F# discriminated union type, mimicking a family of arbitrarily many types.
Inductive proofs can support a powerful type system, but the difficulty in implementing and using such a language is attested to by the fact none of these languages have gone mainstream.
Correctness by construction is sufficient to achieve one simple thing we want from dependent types, granular type representation. And why name them something else when they so neatly fit in with theory? Non sunt multiplicanda entia sine necessitate. 6
Notes
1 Lift usually has a different technical meaning within the context of functional programming. In this case the intuitive notion of lifting the option up a level seems right. What is “lifting” in Haskell?
2 Not everyone has the skill and time to implement complex language features, but everyone can register their opinion as to which features are important to them.
3 The usual caveats about benchmarking apply. You should benchmark your own situation on your own system, etc. There is variance in running the benchmarks multiple times. The variance I saw was typically in absolute run time for each scenario, and not so much in the DependentType / control run time ratios. By and large these results are representative of typical benchmark runs on my system, FSharp.Core 4.5.3, net45 DependentTypes.dll
4 There are several different type theories. Most references in regard to programming and type systems mean some version of the lineage that began in the early 1970's with the work of Per Martin-Löf. For our purposes we refer to the Type Theory outlined in chapter 1 and appendix A of Homotopy Type Theory, Voevodsky, et al.
5 It is worth noting the definitive work on the preeminent dependently typed language, Software Foundations (Vols. I & II), barely mentions dependent types. Tracing the evolution of how proof assistant languages came to be called dependently typed would be an interesting article in its own right.
6 "Entities are not to be multiplied without necessity."
from DependentTypes
type Regex =
new : pattern:string -> Regex + 2 overloads
member GetGroupNames : unit -> string[]
member GetGroupNumbers : unit -> int[]
member GroupNameFromNumber : i:int -> string
member GroupNumberFromName : name:string -> int
member IsMatch : input:string -> bool + 1 overload
member Match : input:string -> Match + 2 overloads
member MatchTimeout : TimeSpan
member Matches : input:string -> MatchCollection + 1 overload
member Options : RegexOptions
...
--------------------
Regex(pattern: string) : Regex
Regex(pattern: string, options: RegexOptions) : Regex
Regex(pattern: string, options: RegexOptions, matchTimeout: TimeSpan) : Regex
val string : value:'T -> string
--------------------
type string = String
type String =
new : value:char -> string + 7 overloads
member Chars : int -> char
member Clone : unit -> obj
member CompareTo : value:obj -> int + 1 overload
member Contains : value:string -> bool
member CopyTo : sourceIndex:int * destination:char[] * destinationIndex:int * count:int -> unit
member EndsWith : value:string -> bool + 2 overloads
member Equals : obj:obj -> bool + 2 overloads
member GetEnumerator : unit -> CharEnumerator
member GetHashCode : unit -> int
...
--------------------
String(value: nativeptr<char>) : String
String(value: nativeptr<sbyte>) : String
String(value: char []) : String
String(c: char, count: int) : String
String(value: nativeptr<char>, startIndex: int, length: int) : String
String(value: nativeptr<sbyte>, startIndex: int, length: int) : String
String(value: char [], startIndex: int, length: int) : String
String(value: nativeptr<sbyte>, startIndex: int, length: int, enc: Text.Encoding) : String
String.Trim([<ParamArray>] trimChars: char []) : string
Regex.IsMatch(input: string, startat: int) : bool
type PercentValidator =
inherit Pi<unit,float,float option>
new : unit -> PercentValidator
--------------------
new : unit -> PercentValidator
type Pi<'Config,'T,'T2> =
new : config:'Config * pi:('Config -> 'T -> 'T2) -> Pi<'Config,'T,'T2>
member Create : x:'T -> 'T2
--------------------
new : config:'Config * pi:('Config -> 'T -> 'T2) -> Pi<'Config,'T,'T2>
val float : value:'T -> float (requires member op_Explicit)
--------------------
type float = Double
--------------------
type float<'Measure> = float
union case DependentType.DependentType: 'T2 -> DependentType<'Pi,'Config,'T,'T2>
--------------------
type DependentType<'Pi,'Config,'T,'T2 (requires 'Pi :> Pi<'Config,'T,'T2> and default constructor)> =
| DependentType of 'T2
override ToString : unit -> string
member Value : 'T2
static member ConvertTo : x:DependentType<'x,'y,'q,'r> -> DependentType<'a,'b,'r,'s> (requires 'x :> Pi<'y,'q,'r> and default constructor and 'a :> Pi<'b,'r,'s> and default constructor)
static member Create : x:'T -> DependentType<'Pi,'Config,'T,'T2>
static member Extract : x:DependentType<'Pi,'Config,'T,'T2> -> 'T2
static member TryCreate : x:Option<'T> -> DependentType<'Pi,'Config,'T,'T2> option
static member TryCreate : x:'T -> DependentType<'Pi,'Config,'T,'T2> option
from Typeallthethings
type PercentValidator =
inherit Pi<unit,float,float option>
new : unit -> PercentValidator
--------------------
new : unit -> PercentType.PercentValidator
static member DependentType.TryCreate : x:'T -> DependentType<'Pi,'Config,'T,'T2> option
type DigitsValidator =
inherit Pi<int,string,string option>
new : config:int -> DigitsValidator
--------------------
new : config:int -> DigitsValidator
val int : value:'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
type ValidDigits =
inherit DigitsValidator
new : unit -> ValidDigits
--------------------
new : unit -> ValidDigits
type ValidDigits2 =
inherit DigitsValidator
new : unit -> ValidDigits2
--------------------
new : unit -> ValidDigits2
type ValidDigits3 =
inherit DigitsValidator
new : unit -> ValidDigits3
--------------------
new : unit -> ValidDigits3
type ValidDigits4 =
inherit DigitsValidator
new : unit -> ValidDigits4
--------------------
new : unit -> ValidDigits4
from Typeallthethings
type ValidDigits =
inherit DigitsValidator
new : unit -> ValidDigits
--------------------
new : unit -> DigitsDef.ValidDigits
type ValidDigits2 =
inherit DigitsValidator
new : unit -> ValidDigits2
--------------------
new : unit -> DigitsDef.ValidDigits2
type ValidDigits3 =
inherit DigitsValidator
new : unit -> ValidDigits3
--------------------
new : unit -> DigitsDef.ValidDigits3
type ValidDigits4 =
inherit DigitsValidator
new : unit -> ValidDigits4
--------------------
new : unit -> DigitsDef.ValidDigits4
| PositiveInt of int
| Zero of int
| NegativeInt of int
type IntSumTypeDiscriminator =
inherit Pi<unit,int,IntegerOfSign>
new : unit -> IntSumTypeDiscriminator
--------------------
new : unit -> IntSumTypeDiscriminator
from Typeallthethings
type IntSumTypeDiscriminator =
inherit Pi<unit,int,IntegerOfSign>
new : unit -> IntSumTypeDiscriminator
--------------------
new : unit -> SumType.IntSumTypeDiscriminator
member Clone : unit -> obj
member CopyTo : array:Array * index:int -> unit + 1 overload
member GetEnumerator : unit -> IEnumerator
member GetLength : dimension:int -> int
member GetLongLength : dimension:int -> int64
member GetLowerBound : dimension:int -> int
member GetUpperBound : dimension:int -> int
member GetValue : [<ParamArray>] indices:int[] -> obj + 7 overloads
member Initialize : unit -> unit
member IsFixedSize : bool
...
from Microsoft.FSharp.Core
type PairPercentValidator =
inherit Sigma<unit,float,float option>
new : unit -> PairPercentValidator
--------------------
new : unit -> PairPercentValidator
type Sigma<'Config,'T,'T2> =
new : config:'Config * pi:('Config -> 'T -> 'T2) -> Sigma<'Config,'T,'T2>
member Create : x:'T -> 'T * 'T2
--------------------
new : config:'Config * pi:('Config -> 'T -> 'T2) -> Sigma<'Config,'T,'T2>
union case DependentPair.DependentPair: 'T * 'T2 -> DependentPair<'Sigma,'Config,'T,'T2>
--------------------
type DependentPair<'Sigma,'Config,'T,'T2 (requires 'Sigma :> Sigma<'Config,'T,'T2> and default constructor)> =
| DependentPair of 'T * 'T2
member Value : 'T * 'T2
static member Create : x:'T -> DependentPair<'Sigma,'Config,'T,'T2>
from Typeallthethings
type PairPercentValidator =
inherit Sigma<unit,float,float option>
new : unit -> PairPercentValidator
--------------------
new : unit -> PercentType2.PairPercentValidator