Hello,
I was talking in the Make Null a subclass of AnyVal under -Yexplicit-nulls and things may be getting a little bit off topic, so I want to separate out my topic.
As a recap, the primary issue I’m declaring with this proposal is java interop, specifically with this problem from the Kotlin KEEP definitely non-nullable types proposal:
public interface JBox {
<T> void put(@NotNull T t);
}
Basically, in Kotlin implementing this type required some way to say “T but without null”. Kotlin didn’t have user facing intersection types (they were always part of the spec but never user denotable). They had to add T & Any as an option to remove null.
We in Scala DO have intersection types and can currently express this like so:
T & (AnyRef | AnyVal). Of course this is very scuffed and not really intended or officially supported by the compiler, that being obvious with the new proposal.
What this topic is about if, in the future, we do say Null <:< AnyVal in explicit nulls (and eventually, all worlds as explicit nulls becomes the only type system, with the only variance being if the compiler reports these null errors), how will Scala correctly represent the above type?
What we need is some way to “cast away null” at the type level, and as well the compiler should correctly produce these kinds of types when @NonNull is explicitly used, instead of just skipping it. This ties into my idea to support jSpecify NullMarked and NullUnmarked annotations. The jSpecify spec is quite a good guide as a baseline for any implementation of null checking. Of course we don’t need to follow it to the letter, we aren’t actually tooling for java so we come first. However, for augmented types (basically, types that also have a nullness operator on them), they have some good rules.
Its rules more or less match the current rules scala uses when parsing java code:
- If the type usage is annotated with
@Nullableand not with@NonNull, its nullness operator isUNION_NULLIn scala, this is
T | NullorNullMode.Explicit - If the type type usage is annotated with
@NonNulland not with@Nullable, its nullness operator isMINUS_NULLThis is different from Scala, which instead uses
NO_CHANGE, orNullMode.Skip - If the type usage is the parameter of
equals(Object)in a subclass ofjava.lang.Recordthen its nullness operator isUNION_NULLThis is some jank to prevent sadness with autogenerated record classes in null-marked scopes, as bytecode doesn’t mark the generated
equalsmethod as generated, meaning any manually written ones are can’t be seen as actually manually written. Scala doesn’t do anything with this info, this is purely some jSpecify jank. - If the type usage appears in a null-marked scope, its nullness operator is
NO_CHANGEIn the above linked PR this is indeed the behavior observed. The default mode in a null-marked scope is
NullMode.Skip - By default, its nullness operator is
UNSPECIFIEDIn Scala with flexible types, this is the same as
T?orNullMode.Flexible
There’s an extra important aside that jSpecify has in their spec:
If tool authors prefer, they can safely produce
MINUS_NULLin any case in which it is equivalent toNO_CHANGE. For example, there is no difference betweenFoo NO_CHANGEandFoo MINUS_NULLfor any class typeFoo(nor for any array type or the null type). The difference is significant for intersection types, type variables, and union types.
There is no difference between Foo NO_CHANGE and Foo MINUS_NULL for any class type Foo (or any array type or the null type). The difference is significant for intersection types, type variables and union types.
I think that Scala’s type system can handle this equivalence already, without any special casing. So I don’t really think there’s any harm in changing the @NonNull case from NullMode.Skip to a new NullMode.MinusNull, that adds an intersection with a type that holds all types except null. In current scala that type is AnyVal | AnyRef and in the future with the AnyVal Null proposal it will need to be a new type (my strawman type for this will be NotNull, defined as Any except for Null)
I don’t think Null can ever leave the type tree, for the simple fact that it needs to extend Matchable for c match { case null => ??? } to work. I personally wouldn’t like NotNull to be a magic compiler difference type but if that’s the only magic in explicit nulls world compared to null being the bottom type in non-explicit nulls then I could tolerate it.
Of course this MINUS_NULL business only really matters in a few select cases, but I think its worth keeping in mind. Something else that’s important is jSpecify’s rules also apply to type bounds, so we have to make sure to correctly transform a type parameters bounds.
I know this is quite a large post but I have a lot to say about this feature - I work very heavily with Minecraft mods which the whole deal there is interoping with Java, so any tiny imperfection is something I run into countless times. Some are easily fixable with shim libraries, but with explicit nulls it’s more of a fundamental issue. This isn’t a dig at explicit nulls, it’s greatly improved over the years (I remember ye old days of trying to use it and every thing in Minecraft being X | Null) and I suggest anyone using pure Scala use it.
If anyone has any additional feedback, I’d love to hear it. Thanks for reading this monster of a topic.