Hackle's blog
between the abstractions we want and the abstractions we get.
Types range over values. Some types have unlimited values: String
, Double
, List
, Map
, etc. Others have known numbers of values: Integer
type has billions of values, Byte
has hundreds, Boolean
has two in true
and false
. Then there is the singleton type: a type with exactly one value. (And then there is the empty type - or the proper "void" - with no value at all.)
The most important quality of a singleton is, "if you know the type, you know the value". This may appear unsurprising if not borderline boring, but when utilised well, especially in combination with generics, singletons can be great help towards type safety and expressivity.
By far the most popular singleton is the unit type, taking the form of void
in C# / Java, ()
in Haskell / OCaml / F#, Unit
in Kotlin, etc. Because the precise 1:1 mapping from the type to the value, they can sometimes share the same notation, resulting in (somewhat funny) expressions such as val foo: Unit = Unit
.
The unit type is boring and a bit fishy; a function foo : ?? -> Unit
must have side effect to be useful beyond the totally legit but meaningless implementation return Unit
. So Unit
typically signals side effect, especially as the return type.
Things get more interesting when we define our own singleton types. Take the common "singleton" design pattern that keeps a static instance of a class, and "redirect" any instantiation to that static instance to ensure "singularity". In a modern language like Kotlin, such manual hassle is streamlined with data object
, as follows,
data object Foo {
val name = "I am foo"
}
val foo: Foo = Foo
Just like Unit
, Foo
is both a type and a value. Its usage is also similar to val foo: Unit = Unit
. However, a data object
can encapsulate arbitrary data, and is therefore both more interesting and useful.
Typically data object
is used when we need a data class
without any parameter, which is not allowed by Kotlin. Yet it's allowed by C# for record
, roughly the data class
equivalent, as follows,
record struct Two
{
public static int Value => 2;
}
var two = new Two();
Because the only field Value
is static
, and a record struct
is "sealed" and non-extensible, in effect, Two
is a singleton. It may not be as cute as data object
, but is logically sound without resorting to extra syntax.
Yet a less-known alternative is called "singleton enum", as the name suggests, it's an enum type with just one value. A singleton enum Two
can be defined as follows,
enum class Two(val value: Int) {
VALUE(2)
}
val foo: Two = Two.VALUE
Compared to data object
, a singleton enum requires discipline to stay a true "singleton". There is no language-level constraint to stop anyone from adding another value ALSO(3)
, or indeed, remove the only value so it's empty!
In return to the lapse of soundness, the constant values of an enum are enumerable at runtime, in the case of Kotlin, it's with enumValues<T : Enum<T>>()
, as follows,
// with kotlinc
>>> enumValues<Two>()
res1: kotlin.Array<Two>
By comparison, there is no such preferential treatment for data object
. This can be the game changer when we want to get the value of an arbitrary singleton.
The singletons so far may look strange but still somewhat conventional. In more expressive type systems, such as that of TypeScript, values can be lifted directly into types, making the creation of singletons almost trivial.
const onlyTrue: true = true
const mustBeFive: 5 = 5
const fooInBar: { 'bar': { 'foo': 1 } } = { 'bar': { 'foo': 1 } }
Here, onlyTrue
is not just of type boolean
, but true
, the same as its value, making true
a singleton. The same goes for 5
and { 'bar': { 'foo': 1 } }
(not considering variance or reference equality, so it's a bit of a stretch).
Besides the usual case of using Unit
(or ()
, void
) to signal side effects, or globally unique objects as a performance optimisation or convenience trick, singletons are actually a handy design tool in expressing logical invariants that other types fall short.
One low-hanging fruit is to take advantage of the lack of choice. Consider the following example,
enum class HasAcceptedTermsAndConditions(val value: boolean) {
YES(true)
}
fun createAccount(hatac: HasAcceptedTermsAndConditions) {
// by this point, T&C must have been accepted!
}
To call createAccount
, one must accept the terms and conditions. Without using singletons, one may use a parameter hatac: boolean
, and validate its value must be true
, or an error is produced. (Note we cannot default hatac
to true
, for legal reasons.)
However, knowing that createAccount
does not take false
for an answer, why do we give the choice at all? That's why the singleton enum HasAcceptedTermsAndConditions
is so handy: it only accepts value YES(true)
. An explicit choice is given and must be made, as legally required, even though it's not that much of a choice.
More sophisticated usage of singletons are usually tied to generics, or in this case, more fittingly by the other name "parametric polymorphism". When used as type parameters, unlike other types, a singleton type also carries the absolute guarantee of the only value available. Now, this is nothing to sneeze at - strong guarantees like this are hard to come by in programming!
To see such generics in action, we first need a type to range over the singleton types. To keep it simple, let's narrow the scope down to singleton of integers. This takes us to ISingletonInt
as below, defined in C#,
public interface ISingletonInt
{
abstract static int Value { get; }
}
record struct Two : ISingletonInt
{
public static int Value => 2;
}
// same goes for Three, Four, Five, you get the idea
This may look deceptively simple, but having a static field in an interface is a pretty big deal. It's made possible only in C# 11 with []"static abstract member methods in interfaces"](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-11.0/static-abstracts-in-interfaces). In our case, the interface is able to specify that any implementing type must have a type level (versus instance level) property Value
.
Now we can use ISingletonInt
as a bound to make FixedSizeList
,
public record struct FixedSizeList<TSize, T> where TSize : ISingletonInt
{
// 1: hide the public constructor and force construction through Create
public ImmutableList<T> Value { get; init; }
FixedSizeList(ImmutableList<T> checkedValue) => Value = checkedValue;
// 2: use the value of the TSize singleton
public static FixedSizeList<TSize, T>? Create(
ImmutableList<T> uncheckedValue
) =>
uncheckedValue.Count == TSize.Value ?
new(uncheckedValue) :
default;
}
// ok
var listOf2 = FixedSizeList<Two, string>.Create(["foo", "bar"]);
// construction failed - returns null
var listOf2Bad = FixedSizeList<Two, string>.Create(["foo"]);
FixedSizeList
is designed with the smart constructor Create
(or "factory method"), by hiding the "primary" constructor and forcing the use of FixedSizeList.Create
so invalid parameters can be rejected with a nullable return type, instead of throwing an exception (which is not visible through types, and therefore considered a side effect!)
The key point is the use of constraint TSize : ISingletonInt
, which makes it possible to pass in the required size through Two
as the type parameter to FixedSizeList<Two, string>.Create()
. There is no need to pass in an instance of Two
as a value parameter, which would have be a form of duplication!
One of the alternatives without using singletons is FixedSizeList<string>.Create(2, ["foo", "bar"])
, which may look similar, but is worlds apart in type safety! Because FixedSizeList<string>.Create(2, ["foo", "bar"])
will have the same type as FixedSizeList<string>.Create(1, ["foo"])
, namely FixedSizeList<string>
, so the size constraint is lost right after construction. By comparison, FixedSizeList<Two, string>
is a different type than FixedSizeList<Three, string>
, so the validation in Create
is preserved and carried with the value, making it impossible to assign the value of one to the other.
Using the same technique, we can design a type Bounded
that must be constructed with a Int
value within range, as below,
public record struct Bounded<TLower, TUpper>
where TLower : ISingletonInt
where TUpper : ISingletonInt
{
public int Value { get; init; }
Bounded(int checkedValue) => Value = checkedValue;
public static Bounded<TUpper, TLower>? TryMake(int uncheckedValue) =>
(uncheckedValue >= TLower.Value && uncheckedValue <= TUpper.Value) ?
new(uncheckedValue) :
default;
}
// Bounded2To5 is a type alias
using Bounded2To5 = Bounded<Two, Five>;
// ok
var bounded = Bounded2To5.TryMake(2);
// fails to construct - returns null
var boundedBad = Bounded2To5.TryMake(6);
Kotlin does not have an equivalent feature to "abstract static member method in interfaces". Indeed, there is no way to encode type-level constraints in an interface - it's meant only for the "instance" level. The traditional "static" methods are created on a companion object
. As such, there is no direct translation of the C# design to Kotlin. We must find another way around.
To recap, our goal is to have an interface SingletonInt
that can be used as below,
interface SingletonInt {
val value: Int
}
fun <T : SingletonInt> retrieveValue(): Int
// so
val mustBe2 = retrieveValue<Two>().value
Without the powerful T.Value
, how do we retrieve the value of a singleton? If we look at the two main methods of definition, the cuteness of data object
cannot be enforced as a type-level constraint. Well, we may use an intention-revealing marker interface MustBeADataObject
, and trust the implementation to be honourable, then we can retrieve the value of a data object
using reflection. Yikes!
If we brought about the dirty word "reflection", why not use singleton enums? It's much simpler, as below,
enum class Two(override val value: Int) : SingletonInt {
_2(2)
}
// also called `reflect`!
inline fun <reified T> retrieveValue(): Int where T : SingletonInt, T : Enum<T> {
return enumValues<T>().first().value
}
OK, it's not exactly trivial, there are a few things to unpack,
Two
faithfully implements SingletonInt
, and has exactly one value. This also means the call to first()
is intrinsically unsafe.reified T
is required to preserve T
for runtime inspection.T : Enum<T>
is key to ensuring that T
is an enum type whose constant values can be enumerated at runtime via enumValues<T>()
.Anecdotally, retrieveValue
can be more formally called reflect
, the action of retrieving a value from a type. Yes that's the same idea as "reflection", although it's often used way too liberally to be remotely as safe as the unsafe first()
.
In short, if we can live with trusting the principled implementations of SingletonInt
, then parity to FixedSizeList
is within reach, as follows,
data class FixedSizeList<TSize, T> private constructor(
val value: List<T>
) {
companion object {
fun <TSize, T> makeUnsafe(unsafeValue: List<T>) =
FixedSizeList<TSize, T>(unsafeValue)
inline fun <reified TSize, T> tryMake(uncheckedValue: List<T>)
where TSize : SingletonInt, TSize : Enum<TSize> =
if (reflect<TSize>() == uncheckedValue.size)
makeUnsafe<TSize, T>(uncheckedValue)
else null
}
}
Admittedly, Kotlin doesn't make this easy. Let's go over a few points,
TSize
available at runtime, it must be reified
, and the function must be inline
. However,inline
function cannot access the private constructor (because it will be inlined to the call site), so the public smart constructor makeUnsafe()
is created but named "unsafe" to warn off direct use. Again, we count on the good behaviour of others.TSize
is retrieved with reflect()
, a.k.a. retrieveValue()
.Once these are accepted as the fact of life, the usage of FixedSizeList
is very similar to that of C#,
val listOf2 = FixedSizeList.tryMake<SingletonInt._2, Int>(listOf(3, 4))
// FixedSizeList(value=[3, 4])
val listOf2Bad = FixedSizeList.tryMake<SingletonInt._2, Int>(listOf(5))
// null
// !! asserts that listOf2 is not null
val listOf5: FixedSizeList<SingletonInt._5, Int> = listOf2!!
// error: initializer type mismatch: expected 'FixedSizeList<SingletonInt._5, Int>',
// actual 'FixedSizeList<SingletonInt._2, Int>'
If you've come this far, great! The deserved reward is implementing Bounded
in Kotlin. Enjoy!
Rust has a feature called "const generics" that allows using constants directly as type parameters, making the above implementations (especially that of Kotlin) fairly unbearable.
Although TypeScript is miles more expressive, because types are erased at runtime, there is no way to implement reflect
, or to retrieve the value of a type without the aide of a runtime value.
Lifting values into types is the first step towards dependent typing. TypeScript is the closest in the mainstream with some flavour in the form of type functions. Dependent typing is a hot pursuit in languages on the cutting edge, such as Haskell, Idris, Agda, Coq, Lean etc. Read into it at your own peril!