> If your value of Y is predicated on receiving an X
We didn't assume it is. Say you have a function of type (String -> String|Null). Further assume that you realize you don't necessarily need a String as input, and that you in fact are able to always output a string, no matter what. This means you can rewrite (improve!) the function such that it now has the type (String|Null -> String). Relaxing the type requirements for your inputs, or strengthening the guarantees for the type of your output, or both, is always an improvement. And there is no logical reason why you would need to change any external code for that. But many type systems are not able to automatically recognize and take advantage of this logical fact.
> > Several other null-safe languages [...] returning Y implies returning Y or Null.
> I have trouble seeing how the language is null-safe in that situation.
If you always assign a value of type Y to a variable of type Y|Null, the compiler will enforce a check for Null if you access the value of the variable, which is unnecessary (as the type of the variable could be changed to Y), but it can't result in a null pointer exception.
Don't mean to appear as talking down to you, but the "relaxation" or "strengthening" that you talk about exactly corresponds to either (1) changing the function that you use at the call site, or (2) changing the "external code" function. The thing you call "improvement" sounds like a plain type error to me.
The mainstream is languages that will happily accept null as anything, and crash at runtime. Sure, union types are cool, but they aren't expressible in most languages, while the optional construct is.
Haskell's type system definitely is a positive example of what can be done to avoid completely the null problem. Is it the utmost that can be done? No. But it's been a working proof of solution for 20 years, while proper typecheckers for union types are a recent thing.
Yeah but there are arguably different standards for Haskell. Haskell's advanced type system is one of its main selling points, so it doesn't make sense to explain the benefits of Haskell with a case (Maybe) where its type system falls short (no "or" in types).
> Haskell's advanced type system is one of its main selling points, so it doesn't make sense to explain the benefits of Haskell with a case where it's type system falls short.
Falls short compared to what? Arguably, if you're talking to someone using Java or Python, Maybe is plenty enough; and getting started on type families is certainly not going to work well.
These languages don't have null safety. Haskell does have null safety, but at the cost of the additional complexity that comes with Maybe wrapping. So it's not as unambiguously an improvement as union typing is (which adds less complexity but still grants null safety).
> Several other null-safe languages [...] returning Y implies returning Y or Null.
If `Y` is implicitly `Y|Null`, then it is no longer possible to declare "this function does not return null" in the type system. Now understanding what a function can return requires checking the code or comments. This is the opposite of null safe.
It isn't. It's just that if you say "this function returns Y or null", and it returns Y, your statement was true. If you give me a hammer, this implies you gave me a hammer or a wrench.
It must. If it is possible to rewrite `X -> Y|Null` as `X|Null -> Y` without changes to external code, then the `X` type needs to accept `X|Null` and the `Y` type needs to accept `Y|Null`, therefore any `T` must implicitly be `T|Null` and the language is not null safe. Result types are what you get when you require explicit conversions.
I may still be thinking about this incorrectly. Do you have an language in mind that contradicts this?
You seem to think `X -> Y|Null` and `X|Null -> Y` have to be equivalent, but that's not the case. The second function type has a more general input type and a more restricted return type. And a function which can accept X as an input can be replaced with a function that can accept X or Null (or X or anything else) as input type. And a function which has can return types Y or Null (or Y or anything else) can be replaced with a function that can return type Y. Old call site code will still work. Of course this replacement only makes sense if it is possible to improve the function in this way from a perspective of business logic.
> I may still be thinking about this incorrectly. Do you have an language in mind that contradicts this?
Any language which supports "union types" of this sort, e.g. Ceylon or, nowadays, Typescript.
I get it! (Thanks, playing around with actual code helped a ton.) For example, in Typescript you're saying you can add a default value simply:
# old
function f(x: number): number {
return 2 * x;
}
# new
function f(x: number|null): number {
x = x || 3;
return 2 * x;
}
# usage
# old
f(2)
# new
f(2) # still works!
But in Haskell this requires changing the call sites:
-- old
f :: Int -> Int
f = (*2)
-- new
f :: Maybe Int -> Int
f = maybe 0 (*2)
-- usage
-- old
f 2
-- new
f (Just 2) -- different!
But I actually feel this is an antipattern in Haskell (and maybe TypeScript too), and a separate wrapper function avoids refactoring while making things even more user friendly.
-- old
f :: Int -> Int
f = (*2)
-- new
fMaybe :: Maybe Int -> Int
fMaybe = maybe 3 f
-- usage
-- old
f 2
-- new
f 2 -- still works!
fMaybe Nothing -- works too!
Here's some wrappers for general functions (not that they're needed, they're essentially just raw prelude functions):
maybeIn :: b -> (a -> b) -> (Maybe a -> b)
maybeIn = maybe
maybeOut :: (a -> b) -> (a -> Maybe b)
maybeOut = fmap Just
maybeBoth :: b -> (a -> b) -> (Maybe a -> Maybe b)
maybeBoth d = maybeOut . maybeIn d
Added bonus, this approach avoids slowing down existing code with the null checks we just added.
This came to mind while considering your interesting point: After such a change, wouldn’t you feel the urge to inspect all users of the stricter return type and remove unnecessary handling of a potential null return?
Good point. In such case I would probably consider leaving the signature as is, even after tightening, and possibly offer a function with stricter signature for new code to use while deprecating the older variant. This would inform the users without rug pulling.
We didn't assume it is. Say you have a function of type (String -> String|Null). Further assume that you realize you don't necessarily need a String as input, and that you in fact are able to always output a string, no matter what. This means you can rewrite (improve!) the function such that it now has the type (String|Null -> String). Relaxing the type requirements for your inputs, or strengthening the guarantees for the type of your output, or both, is always an improvement. And there is no logical reason why you would need to change any external code for that. But many type systems are not able to automatically recognize and take advantage of this logical fact.
> > Several other null-safe languages [...] returning Y implies returning Y or Null.
> I have trouble seeing how the language is null-safe in that situation.
If you always assign a value of type Y to a variable of type Y|Null, the compiler will enforce a check for Null if you access the value of the variable, which is unnecessary (as the type of the variable could be changed to Y), but it can't result in a null pointer exception.