Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

if let construction? #109

Open
dessalines opened this issue Sep 25, 2022 · 3 comments
Open

if let construction? #109

dessalines opened this issue Sep 25, 2022 · 3 comments

Comments

@dessalines
Copy link

dessalines commented Sep 25, 2022

I'm not sure if this would be possible in typescript.

In addition to match, rust also has if let Some(X) = an_opt_var { ....

This is really useful, since often the None case isn't used / ignored.

It's possible to do if (opt.isSome()) { let x = opt.unwrap()... but that's less compiler-checked, and it requires an unsafe unwrap.

I'd like this a lot since my lemmy-ui codebase has tons of these verbose .match whilst ignoring the none case, all over the code.

@jsejcksn
Copy link

jsejcksn commented Oct 1, 2022

It appears that you're focused on this behavior of if let: when using it, you delegate the unwrap operation to a compiler abstraction and then your code block is lazily evaluated with the unwrapped variable provided to you by that abstraction.

TypeScript can't implement Rust's control flow syntax — although I'd bet I'm not alone in wishing it could.

Something that you can use instead is Option#map which looks like this:

(Option<T>).map(fn: (val: T) => U) => Option<U>

The callback is lazy and is only invoked for Some variants. Compare the sources:

Some variant:

map<U>(fn: (val: T) => U): OptSome<U> {
  return some_constructor<U>(fn(val));
}

None variant:

map<U>(_fn: (val: T) => U): OptNone<U> {
  return none_constructor<U>();
}

Is that what you're looking for?

If not, can you explain in more detail what it is that you want from if let?


In case you're interested in more on types...

You also said:

It's possible to do if (opt.isSome()) { let x = opt.unwrap()... but that's less compiler-checked...

There's a subtle difference between using the Option#isSome method compared to using the top-level function export isSome: both return a boolean value, but the function export is also a type guard. Consider this example:

TS Playground

import {isSome, type Option} from '@sniptt/monads';

declare let numOpt: Option<number>;

// Using the instance method:
if (numOpt.isSome()) {
  // The compiler still doesn't know that it has a value. It's still just `Option<T>`:
  numOpt;
//^? let numOpt: Option<number>
}

// Using the function:
if (isSome(numOpt)) {
  // Now the compiler knows that it's `OptSome(T)`:
  numOpt;
//^? let numOpt: OptSome<number>
}

Because of the way that type guards work in TS (return type predicates can only be conveyed about parameter values), zero-argument methods can't be used as type guards for their owner objects. This is why the code above works the way it does.

...and it requires an unsafe unwrap

If you examine these interface types in the source, you can see that the return types are truly sound (type-safe):

export interface Option<T> {
  // --- snip ---
  unwrap(): T | never;
  // --- snip ---
}

export interface OptSome<T> extends Option<T> {
  // --- snip ---
  unwrap(): T;
  // --- snip ---
}

export interface OptNone<T> extends Option<T> {
  // --- snip ---
  unwrap(): never;
  // --- snip ---
}

The "problem" is in the way that never is handled in TypeScript's union types. In unions, never is always absorbed by other types. Here's an example for illustration:

TS Playground

// `never` is always absorbed in unions:

type U1 = string | never;
   //^? type U1 = string

type U2 = any | never;
   //^? type U2 = any

type U3 = unknown | never;
   //^? type U3 = unknown

type U4 = void | never;
   //^? type U4 = void


// `unknown` always dominates other types (except `any`):

type U5 = string | number | unknown | never;
   //^? type U5 = unknown

type U6 = string | number | unknown | never | any;
   //^? type U6 = any

So when using the unwrap method, this happens:

TS Playground

import {isNone, isSome, type Option} from '@sniptt/monads';

declare let numOpt: Option<number>;

if (numOpt.isSome()) {
  numOpt;
//^? let numOpt: Option<number>

  // The return type here is acutally `T | never`, but `never` is absorbed:
  const unwrapped = numOpt.unwrap();
      //^? const unwrapped: number
}

if (isSome(numOpt)) {
  numOpt;
//^? let numOpt: OptSome<number>

  // The return type here is acutally just `T`:
  const unwrapped = numOpt.unwrap();
      //^? const unwrapped: number
}

// Using `isNone`:
if (isNone(numOpt)) {
  numOpt;
//^? let numOpt: OptNone<number>

  // The return type here is soundly resolved:
  const unwrapped = numOpt.unwrap();
      //^? const unwrapped: never
}

@dessalines
Copy link
Author

dessalines commented Oct 2, 2022

The isSome(... function would work, if there was some way to get a compiler to throw errors only on the OptNone or unknown unwraps (and let the OptSome ones pass compiler checks).

The .map doesn't really work for me, because I need to actually be able to return the unwrapped item from the code blocks. Here's an example of what I have to do in my codebase with jsx:

render() {
  return (
    {post.url.match({
      some: url => <div>{url} HERE!</div>,
      none: <></>,
    })} // This statement actually does return `JSX.Element`

I would love to be able to do:

{post.url.let(url => <div>{url} HERE!</div>)} 

In rust, this would be like:

if let Some(url) = post.url {
  <div>{url} HERE!</div>
}

@jsejcksn
Copy link

jsejcksn commented Oct 2, 2022

The .map doesn't really work for me, because I need to actually be able to return the unwrapped item from the code blocks.

Of course: that makes sense — it's quite challenging for values to be useful if they must always stay inside a block scope.

But what if the unwrapped value doesn't exist (a None instead of a Some)? Neither of the code blocks you showed address that possibility:

I would love to be able to do:

{post.url.let(url => <div>{url} HERE!</div>)} 

If post.url were actually a None variant, then what type would that expression evaluate to? undefined maybe (based on the Rust snippet you shared)?

In rust, this would be like:

if let Some(url) = post.url {
  <div>{url} HERE!</div>
}

In that Rust code (assuming that all of the syntax were valid), the React element would still need to be assigned to a variable to be used outside the block, like this:

let react_element = if let Some(url) = post.url {
    <div>{url} HERE!</div>
};

But — in Rust — if expressions without else evaluate to the unit type ().

So, in the case that post.url were actually a Some variant — the expression would evaluate to a React element: JSX.Element (which is essentially just an alias for a ReactElement with any for the generic type parameters). But in the case that post.url were a None variant, the expression would evaluate to () — and this would cause the program to fail to compile because every value has to be a single type. You could store the result in a container type (like Option<ReactElement>), but then you're back to where you started outside the block, except that you've applied a transformation to the Some variant's value (if it existed).

It's simple to fix this so that the program could compile: just supply a default ReactElement in an else block:

let react_element = if let Some(url) = post.url {
    <div>{url} HERE!</div>
} else {
    <></>
};

The TypeScript equivalent of the example Rust block above would be to chain the following methods together:

Putting that together with your JSX example looks like this:

TS Playground

import type {Option} from '@sniptt/monads';

declare let post: { url: Option<URL> };

const reactElement = post.url.map(url => (<div>{url.href} HERE!</div>)).unwrapOr(<></>);
    //^? const reactElement: JSX.Element

However, if you really want to use another type of default, that's certainly possible — TypeScript doesn't have the same requirement for a variable to be of a single type (that's exactly what unions are: one of multiple types). You can do something like this:

TS Playground

import type {ReactElement} from 'react';
import type {Option} from '@sniptt/monads';

declare let post: { url: Option<URL> };

const reactElementOrUndefined = post.url
  .map<ReactElement | undefined>(url => (<div>{url.href} HERE!</div>))
  .unwrapOr(undefined);

And, of course, you can create your own function abstraction to lazily execute and return the result of a callback function depending on the Option variant, like this:

TS Playground

import type {Option} from '@sniptt/monads';

function matchOption <T, SomeResult, NoneResult>(
  opt: Option<T>,
  someFn: (val: T) => SomeResult,
  noneFn: () => NoneResult,
): SomeResult | NoneResult;
function matchOption <T, SomeResult>(
  opt: Option<T>,
  someFn: (val: T) => SomeResult,
): SomeResult | undefined;
function matchOption <T, SomeResult, NoneResult>(
  opt: Option<T>,
  someFn: (val: T) => SomeResult,
  noneFn?: () => NoneResult,
) {
  return opt.isSome() ? someFn(opt.unwrap()) : noneFn?.();
}


// Use:

declare let strOpt: Option<string>;

const upper = (str: string): string => str.toUpperCase();

// Explicitly returning `undefined` in the None callback:
const uppercaseOrUndefined1 = matchOption(strOpt, upper, () => undefined);
    //^? const uppercaseOrUndefined1: string | undefined

// Omitting the None callback always results in `undefined` as the return type
// in the case of a None variant:
const uppercaseOrUndefined2 = matchOption(strOpt, upper);
    //^? const uppercaseOrUndefined2: string | undefined

...which is essentially an ever-so-slightly less verbose version of Option#match, which is what you originally asked about:

I'd like this a lot since my lemmy-ui codebase has tons of these verbose .match whilst ignoring the none case, all over the code.

  • it skips the object syntax in favor of ordered parameters, and
  • the None callback is optional — undefined is returned if it is not provided and the option argument happens to be a None variant.

The type inference is also a bit more flexible because of the generics in the overload signature.

Is this syntactically more concise than match? Maybe — but at the expense of clarity IMO.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants