Type Ignorance: More Than Nullability

I haven’t written the formal docs of it yet, but one of the things I’m really looking forward to implementing in Beagle is type ignorance. Type ignorance is, as I would formally describe it, the description of a type’s known and unknown attributes. This means that you can specify how much of an object’s type you know, including whether it exists, aka nullability. I have created 4 (technically 5) different degrees of ignorance. These different degrees of ignorance get more and more ignorant the high you go. They are as follows:

  • First Degree: Nullability
    This tells the compiler that you know what type an object is but you don’t know whether it will exist at any given time. This is an already widely known and used feature in several languages. This is often denoted as a ? sigil after a type annotation, like so (in Beagle syntax)
    def val a: A? = null

  • Second Degree: Type Category
    This tells the compiler that you don’t know what type an object is but you know what “type category” it belongs to, either a class or a struct. This allows you to perform more complex pattern matching on this object to perform what I call type elaboration (I’ll show this later). To declare something of being type ignorant to the second degree, using class as an example, is as follows:

def fun registerSomething(item: class?, size: Int){
    
}

You can further declare something as being a class but also nullable by appending a second ? sigil.

def fun registerSomething(item: class??, size: Int)
  • Third Degree: Pseudotypes
    This tells the compiler that you don’t know what type it is but you know what kind of pseudotype it is. Pseudotypes are types that are only known at compiletime but are lost at runtime. This includes traits and abstract types. Traits are a way of abstracting compositions of structs. Abstract types allow you to define abstractions that can be implemented by both classes and structs. Abstract types get converted at compiletime to the appropriate abstract, either an interface (which is a type) or a trait (a pseudotype). This is what makes abstract types psuedotypes. The syntax looks about like this:
def trait T1{}
def type AT{}
//register takes an object that is a struct that 'comes with' trait T1.
def fun register(item: struct? with T1){}
//register takes an object of anything that implements abstract type AT
def fun register(item: type? of AT){}
  • Fourth Degree: Absolute Ignorance aka Opaque Types
    Absolute ignorance means you don’t know anything about these types. These are equivalent to opaque types in C/C++. These are denoted by just a single ? sigil. This allows anything to be passed to it. This also allows you to elaborate as much as you want.
def fun register(item: ?){} 

You can specify this object as being nullable, like mentioned before, with a second ?

def fun register(item: ??){}

This allows much cleaner bindings with C/C++ libraries, since lots of them will be using opaque types and void pointers.

Now moving on to type elaboration. It’s the utilization of pattern matching to elaborate on what type something is. It would be best to go through the layers for the most accurate elaboration in conjunction with clean and safe error handling.

An example:

def class A
def trait T1
def struct B with T1

def val classRegistry = ArrayList<class?>() //An ordered mutable set
def val structRegistry = Scalar<struct?>() //A mutable vector
def val registryOfA = ArrayList<A>()
def val registryOfB = Scalar<B>()
def val registryOfT1 = Scalar<struct? with T1>()

def fun register(item: ?): Result<Unit>{
    match(item){
        class? -> {
            classRegistry.add(it)
            match(it){
                A -> registryOfA.add(it)
                else -> Result.Error("Found a class but could not match a known concrete type")
            }
        }
        struct? -> {
             structRegistry.add(it)
             match(it){
                 struct? with T1 -> {
                     registryOfT1.add(it)
                     match(it){
                           B -> registryOfB.add(it)
                           else -> Result.Error("Found a struct with trait T1 but could not match a known concrete type")
                    }
             }
             else -> Result.Error("Found a struct but could not match a known concrete type")
        }
        else -> Result.Error("Could not elaborate ignorant type")
    }
}

This example doesn’t cover elaborating on abstract types but I’m sure you get the idea by now. I honestly cannot wait to get this feature implemented in Beagle. It’s gonna be really nice to have. This feature is still a WIP feature, but when I come across that road, I’ll definitely work out the kinks.

3 Likes

I really like the idea of this. I think that expanding on this concept one could get some of the flexibility I loved in Ruby with the comfort of static checking.
Personally I would love to have the possibility to state the “expectations” I have on a certain parameter, including, let’s say, the fact it has a certain field or a method with a certain signature, even if it does not implement a certain interface.
This would be useful when want to use a certain set of classes from a library. Maybe the classes have methods with a certain signature but those methods are not defined in a small interface, maybe they are part of a large class or a complex interface. You may want to be able to write a function which works with objects having those certain methods, some of which will implement classes coming from the library, and some of which you will implement yourself.
I am a bit sleepy so I am not sure if I managed to explain myself :smiley:

2 Likes

Actually, I was going to write about that in another post. That’s a separate thing called “data validation”. You can declare a “data validation rule” like this:

def rule LenGreaterThanInclusive<Sized>(minSize: Int){
    if(it.size < minSize){
        return Error("Size must not be less than $minSize")
    }
}

These rules are expanded internally into generic functions, so you can actually write these rules the same way you write functions (I’ll explain how that works when I write about def macros). Then when you want to use the rule, there is a special sigil for that. It’s part of the type annotation system.

def fun register(username: @LenGreaterThanInclusive(5) String)
//Somewhere else
let result = register("alex") //This would return an error since my name is not 5 chars
match(result){
    Ok -> {
        //Do other stuff
    }
    Error(message) -> {
        panic("Failed to create user: $message")
    }
}

So when you put this together with type ignorance you can do something like this:

def fun register(item: @ApplySomeRule class?)

The idea is definitely intriguing. The first things that come to mind though are: 1) How different is this from generics? 2) What’s the advantage of this over generics? 3) How do you prevent things from being out-of-control?