> In Rust we have Option<T>, which is equivalent to T | null
No, not true!
As the author correctly states earlier in the post, unions are not an exclusive-or relation. Unions are often made between disjoint types, but not always.
This becomes important when T itself is nullable. Let's say T is `U | null`. `Option<Option<U>>` in Rust has one more inhabitant than `U | null | null` in TypeScript - `Some(None)`.
Union types can certainly be very useful, but they are tricky because they don't compose like sum types do. When writing `Option<T>` where T is a generic type, we can treat T as a totally opaque type - T could be anything and the code we write will still be correct. On the other hand, union types pierce this opacity. The meaning of `T | null` and the meaning of code you write with such a type does depend on what T is at runtime.
Rich Hickey has a presentation titled 'Maybe Not' that talks about this exact distinction, but he actually argues the reverse (and often criticized quite wildly, even though both sides are sort of right here, I believe). He says that nullability is better [1], as refactoring a function that accepted T to T?, or a function's return type from T? to T are both backwards compatible, while the sum type variants require code change.
Your last sentence put the whole argument even better in place in my head, it depending on the runtime is both a blessing and a curse and is what let's us change function signatures in a backwards compatible way, while also what hinders our ability to reason/encode stuff in it statically.
[1] I think part of the misunderstanding here between the "two camps" is that some people work on systems that are a closed world. You know and control everything, so an all-encompassing type system that can evolve with the universe itself makes the most sense. Hickey on the other hand worked/works mostly in an area where big systems developed by completely different entities have to communicate with each other with little to no coordination. You can't willy nilly refactor your code and just trust the compiler to do its job, this is the open sea. Also, I think this area is a bit neglected by more modern/hyped languages, e.g. the dynamicism that the JVM has is not really reproduced anywhere anymore?
> as refactoring a function that accepted T to T?, or a function's return type from T? to T
I've always found this to be a silly argument. In both cases, the problem can be solved by interface versioning. If you have f : T → Maybe T and you should like to change the type, just create f_v2 : Maybe T → T assign f x = just (f_v2 (just x)). The real shame is that most languages do not support interface versioning as a proper feature. There should really be some way to use a package and declare that you want to use v1/2 of the interface, then subsequently package.f would either be f or f_v2. You would eventually have to change the code that deals with f if you want to stay up to date, but realistically you should remove redundant error checking code anyway so you aren't much better off.
You could also frame it as a tooling problem. Given that the transformation between types is trivial, you should be able to write a program that automatically updates code to newer versions. It seems like this is a relatively niche issue in either direction (union types not being disjoint, vs having to change code that deals with options), but its a more solvable problem in the second case.
Do you know of a language that supports interface versioning? I often wished for one (as I like writing programs by "iterative refining", where I basically copy the good parts into a completely new project), but have never heard of a language that does something like that.
Sadly, I do not. You can emulate it pretty well with just putting numbers on the end of function names though. I'm always a little shocked at how the people who make programming languages seem to consistently ignore some of the most obvious features in favour of weird stuff.
Yes, technically it is closer to (T | null) & {__tag: K}, but the context where "equivalent" is used is clearly about practical usage. Option<T> is most similar to T | null in code people actually write on a normal basis.
Sharing my TypeScript Result type for anyone who’s crazy like me and wishes they had “if let” and doesn’t want “.value” everywhere.
You can do this:
const user = await fetchUserDTO(123);
// user is Result<UserDTO, Error>
if(!isOk(user)) { something(user.err); return; }
user.name; // user is narrowed to UserDTO
Source:
export const FailSym: unique symbol = (globalThis as any).____FailSym ?? Symbol('FailSym');
(globalThis as any).____FailSym = FailSym;
export type Result<T, E> = T | {
[FailSym]: true,
err: E
}
export function Ok<T>(val: T): Result<T, never> { return val; }
export function Fail<E>(err: E): Result<never, E> { return {[FailSym]: true, err}; }
export function isOk<T, E>(val: Result<T, E>): val is T { return !(typeof val == 'object' && val != null && FailSym in val); }
export function isFail<T, E>(val: Result<T, E>): val is Result<never, E> { return typeof val == 'object' && val != null && FailSym in val; }
export function resultify<T, E>(promise: Promise<T>): Promise<Result<T, E>> {
return promise.then(Ok, err => Fail(err as any));
}
> The meaning of `T | null` and the meaning of code you write with such a type does depend on what T is at runtime.
Could you give an example of this? The only case I can imagine is a union null <=: T, T | null. This would cause unexpected behaviour, i.e. a if T = (U | null), the function orElse : T | null → T → T would always return the second argument if the first is null, but this is perfectly consistent with the fact that T itself is a union type. The idea that it should do anything different is presuming that the null assigned to T is different from the null assigned to T | null (that union types are disjoint). But this is an invalid assumption. It's still a validly typed program that obeys a consistent set of rules. It's just that there is no tagging in the union type (you don't know if a member comes from the left or right side). This is only an issue if you write code expecting the union to work like a tagged union.
> but they are tricky because they don't compose like sum types do
Just to point that this part is very literally true. They compose perfectly well, but they don't compose on the same way that tagged unions do. Tagged unions compose by function abstraction, untagged ones compose by function dispatching.
Maybe one can argue that untagged unions compose in less useful ways. But I've never seen this argument made.
No, not true!
As the author correctly states earlier in the post, unions are not an exclusive-or relation. Unions are often made between disjoint types, but not always.
This becomes important when T itself is nullable. Let's say T is `U | null`. `Option<Option<U>>` in Rust has one more inhabitant than `U | null | null` in TypeScript - `Some(None)`.
Union types can certainly be very useful, but they are tricky because they don't compose like sum types do. When writing `Option<T>` where T is a generic type, we can treat T as a totally opaque type - T could be anything and the code we write will still be correct. On the other hand, union types pierce this opacity. The meaning of `T | null` and the meaning of code you write with such a type does depend on what T is at runtime.