using 'Maybe' object to make null/undefined/errors more semantic

In real world, there's no null/undefined thing, instead it's a famous bug and mistake in early age of programming language design, instead, all these things should be result in errors, it's a tricky way to avoid handling the errors.

But nowadays, we use null/undefined for special cases/unheathy/uncertain(optional) states, but some times, people don't pay attention to these states, and result in errors caused by null/undefined.

So the Maybe comes into play.

Assume some function called maybeTrueOrEmpty maybe return unhealthy states (null/undefined/errors), just wrap it in Maybe and test if it's ok:

    const maybe = Maybe(maybeTrueOrEmpty(i))
    if(maybe.ok){
        console.log("value:", val!.x)
    } else {
        console.log("Unhealthy state!")
    }

The full text of proposal is here: GitHub - futurist/proposal-maybe

2 Likes
var a = Maybe({x: Maybe({y: 1})});
a?.x?.y === 1 ; // ---> Should discuss it's behavior here

var b = Maybe();
(b ?? "ok") === "ok"; // This should be same as b.ok ? b! : "ok";

var c = Maybe(3);
(c ?? "ok") === 3;    // This should be same as c.ok ? c! : "ok";

Added parentheses because without them it wouldn't parse as you intended.

What do these print (or throw):

console.log(typeof a);

console.log(b);
console.log(b || "default");

console.log(c);
console.log(20 + c);
2 Likes

Thanks, already updated!

I'm excited to see where this discussion heads :).

Here's a handful of thoughts on the matter.

Can we make the maybe type immutable (frozen)? Otherwise we'll end up in situations like this:

const maybeUser = getUser(userId)
if (maybeUser.ok) {
  processUser(maybeUser)
  // The following may throw, because processUser may have
  // changed the state of maybeUser
  console.log('done processing user ${maybeUser!.name}`)
}

The benefit of mutability is that it can be more convenient to mutate a parameter than to return a new instance. The downside is that it inevitably makes code much harder to follow, like the above snippet. I would argue that a scenario like the above is just as dangerous as null/undefined.

Next, you never really explained what Maybe.none is in the proposal, but I assume it's just a sentinel to distinguish an "Empty" bad-state maybe value from one that holds an error. If this is the case, Maybe.none is literally just another variation of null or undefined, just under a different name. Let's compare the following code examples

// If an unwrapped error set's its "value" property to
// Maybe.none, as currently proposed
try {
  maybeValue.unwrap()
} catch (err) {
  if (err.value === Maybe.none) {
    console.error(`Orriginal error message: ${err.value.message}`)
  }
  throw err
}

// Here's how the API usage would look like
// if err.value got set to null instead
try {
  maybeValue.unwrap()
} catch (err) {
  if (err.value === null) {
    console.error(`Orriginal error message: ${err.value.message}`)
  }
  throw err
}

// It's literally the same thing

I think the underlying issue here is that we're combining two different things, the Option type and the Maybe type, into one thing in this proposal, and we're trying to make the distinction with a special Maybe.null sentinel. These two types serve different purposes, and I think it'll be best to make an Option type separate from a Maybe type. The Maybe.null thing isn't the only issue with having them combined - another is that it encoraged bad practices. For example:

This is bad practice

try {
  return getUser()
} catch {
  return null
}

Because we're swallowing all errors, even unexpected programming failures. Instead, it's better practice to single out the specific type of error you're expecting and handle just that case:

try {
  return getUser()
} catch (err) {
  if (err instanceof UserNotFoundError) {
    return null
  }
  throw err
}

Likewise, using .else() on a maybe type that holds an error should be considered bad practice. If we had a separate option type, we could simply just not provide this .else() method on the Option type, and only have it exist on Maybe.

function getUser() {
  try {
    ...
    return Maybe.up(...)
  } catch (err) {
    return Maybe.down(err)
  }
}

function legacyGetUser() {
  // Bad practice! This swallows all errors that may
  // have been contained in the Maybe type.
  return getUser().else(null)
}

Thank you @theScottyJam for the many cases discussed, below are some of my thoughts:

- For immutable

I think the immutable one should not be included in this proposal, the case is more widely affected than Maybe itself, and make things more complex.

- For null and error handling

The Maybe(null).unwrap() always throw a e: MaybeError, and the e.value will be Maybe.none, and the actual value of Maybe.none should be discussed, maybe it's just the same as null, but using it can be more semantical for programmers, that to say: "Hey it's unhealthy and have empty error value: none".

The null semantic in Maybe is an empty error (but it is an error).

And Maybe also want to let JS code more friendly to try...catch to avoid using it, and led to rust way of error handling using ?(here we use !), by nested Maybe, that is, One thing is maybe, all parents affected will become Maybe, that way, will led to have more elegant way to handle errors than many try...catch.

I think using ! to handle the error is better than rust ?, is that ! is lazy (later binding when used), and the error produced by Maybe will not immediately return, so have a chance to let user handle it in the same function scope.

Think below code:

function getUserName(url) : Maybe<String, NetworkError> {
  const user = maybeGetUser(url);
  return Maybe(user!.name)
}

The ! is syntax sugar of unwrap, so if cannot unwrap, error type should be NetworkError(DownState), else you get String type name (UpState).

Instead, the try..catch way:

function getUserName(url) : String | null {
  let user
  try{
    user = getUser(url);
    return  user.name
  }catch(e){
    console.log(e.message)
    throw e
  }
}

The above code:

  1. Not elegant, too much boilerplate for error handling all the place.
  2. The return value is largely user defined (e.g., it's also may return an object to hold the NetworkError), people have to lookup the signature constantly to know the API, but hard to know what's errors will be thrown.

For the Maybe version:

  1. More semantic and elegant.
  2. People know there's some errors maybe, like Java's function throws keyword does, the error message in the level of language, one pattern for many.
  3. The caller should handle the errors, and stop return Maybe, or handle it then wrap it in another Maybe to let parents know it is a maybe.

- For .else hold an error

If the .else hold an error, it's indeed bad practice but if it has semantic to user, like Maybe<UseInfoError, NetworkError>, it's up to user to decide the semantic, cannot be defined in the proposal, but that led to a topic: Error as UpState, I think that case should also be deeply discussed also.

I don't get this example. This function doesn't return a Maybe. return user!.name either returns a plain string (UpValue.name), or throws whichever DownValue user contains.

That's comparison with a terrible practice of mixing results with errors. The correct "try..catch" way is simply returning values, and (re)throwing errors:

function getUserName(url) : String {
  const user = getUser(url);
  return  user.name
}

You're right, corrected! @lightmare

Perhaps I didn't explain well what I was looking for. It wouldn't be any more complex than promises. Once a promise settles, you're unable to change it from resolved to reject. The settled state is immutable.

Similarly, if you just take out functions such as maybeInstance.down(...) which mutates the state of the Maybe instance from up to down state, then you've effectively achieved the immutability I was looking for. (I don't know why I used the word "frozen" - what I want immutable has nothing to do with freezing the instance).

I don't see how wrapping the existing behavior of undefined / null into a wrapper object makes anything better. Especially as the variable holding the Maybe value could itself be undefined if one forgets to initialize it, so there would then basically be three "no value" states.

I think the actual solution to the "billion dollar mistake" is to distinguish nullable and non-nullable types and make access to them explicit. In Kotlin for example I know that a.b always works, and if a would be nullable I would have to add an explicit non null assertion a!.b or add a null check.

I'd also like to point out that with Typescript I never saw a "Cannot read property b of undefined", so it is already quite possible to get this safety at compile time in a type system built on top of JavaScript. I don't think that the same safety can be created without variable types.

On a final note although "undefined" and "null" are quite strange from a language design perspective, there seems to be some common sense that undefined signifies an unknown / uninitialized state, whereas null means "empty".

1 Like

@Jonas_Wilms - I think you're absolutely right that there's much less value for a maybe type like this in a non-type-safe language. But, I'll still point out a couple of places where it adds value.

Take this example:

// Exhibit A
function getUser(id) {
  const data = await fetchResourceById(id)
  // ...
  return data.unwrap()
}

// Exhibit B
function getUser(id) {
  const data = await fetchResourceById(id)
  // ...
  return data
}

In exhibit A, the code self-documents the fact that fetchResourceById() is capable of returning an empty value, but the developer explicitly stated that they don't think it'll happen in this context, and so unwraps it.

In exhibit B, it's much more difficult to know the developer's intentions. When they wrote that code, did they realize that fetchResourceById() might return null? Did this properly account for what would happen if it did? (If you're using typescript, this is not actually an issue, because the explicit type annotations will let you know what the developers intentions were).

The other type-safe benefits of a maybe type can be reaped by those who choose to use type-safe variants of Javascript. However, as already pointed out, typescript does a better job than other type-safe languages with dealing with null, because they allow null to be part of the type. The billion dollar mistake in other languages was that anything could be null, and there were no type annotations to restrict this.


With that said, I think there's going to be an extra high bar for introducing a feature like this. The moment it goes into the spec, all existing libraries (built-in or userland) will effectively become legacy, and there will be a big push to update or create new functions that use maybe types instead of null/undefined. We're still trying to update everything since promises came out - we've got functions like setTimeout() which uses the legacy callback way of doing things, and it can be frustrating to use it in a promise-based environment.

So, do the ends justify the means? As it stands, I don't think so, but I'm still interested to see how this conversation will evolve.

That part is determined solely by fetchResourceById() API, i.e. achievable by it returning an instance of userland class Maybe.

But this proposal asks for expanding the definition of nullish in the language, by redefining nullish-aware operators. Which is why I asked above what would certain expressions print. I still don't know whether the proposal introduces a new primitive type, or a class.

.else(fallbackValue) -> Value

  1. Call and return .unwrap when in UpState
  2. Or return a fallbackValue value when in DownState, fallbackValue can be a function

What if I want the fallback value to be a function? If someone wants to call a function on the down state, you already have maybe.map(x=>x, fn) for that.

Another issue, quoting from "Use ! to make try...catch more elegant example":

async function getUserName(url){
    // fetch may be throw!
    const response = await getMaybe(fetch)(url);  // response is Maybe
    // Using Maybe, above `await` will never need to try...catch
    if(response.ok && response!.ok) {
        const user = await getMaybe(response!.json)() // user is Maybe
        return user.else(defaultUser).unwrap().name
    }
}

This won't work. You'd need some syntax for bound method extraction to avoid writing getMaybe(response!.json.bind(response!))(). Probably easier to do callMeMaybe(() => response!.json()) or not bother with extra wrappers and do response.map(r=>r.json()).

.else(defaultUser) returns a value, shouldn't then .unwrap()

You're swallowing errors, if fetch fails you don't log anything and return undefined. If you fix that example to behave properly, it will look almost exactly like try..catch code, only with different words expressing the same idea:

async function getUserName(url){
  try {
    // fetch may be throw!
    const response = await fetch(url);
    if(response.ok) {
        const user = await response.json()
        return user.name
    }
  } catch (e) {
    console.log('failed to fetch user, returning default', e);
    return defaultUser;
  }
}

To me this seems less cluttered and more concise, i.e. fewer typopportunities.

The usefulness of a Maybe type has nothing whatsoever to do with null or undefined. Just like a Promise can reject or fulfill with any value, a Maybe would as well. In other words, Maybe is and should be just conceptually a sync version of Promise - an object that represents one of two branches, and a value (null, undefined, or literally anything else).

1 Like

Are you by chance mixing up the maybe type with the either type?

  • A maybe type either has a value or is empty.
  • An either type is either in a happy/up state with a value, or a sad/down state with a value

This proposal has attempted to combine the two ideas into a single thing. It acts more like an either type, yet it's called a Maybe type. And the primary problems it's claiming to solve align more with the Maybe type's purpose, which is why we keep talking about null/undefined - In other languages, the maybe type is supposed to be a direct replacement of null/undefined, which is why those languages don't have null.

2 Likes