DEV Community

Cover image for RSLike@3. Well-known Symbols, Improved Usage of TypeScript, and Weighing More
Vitali Haradkou
Vitali Haradkou

Posted on

RSLike@3. Well-known Symbols, Improved Usage of TypeScript, and Weighing More

Not long ago, I released a library that was meant to eliminate errors related to null and undefined. Honestly, I admit that I decided to borrow from Rust's Option and Result APIs, as I saw potential and convenience in it!

To briefly recount the history of creating this marvel, while studying Rust, I saw the potential of these wrappers. And after being inspired, I decided to write such a marvel myself for JavaScript and use it in my projects (more on that later). Quite quickly, version 1 appeared, followed by a bunch of fixes (here), then version 2 emerged, introducing the cmp package and dbg. And only recently (April 10, 2024), version 3 for all packages saw the light of day: std, cmp, dbg.

Introduction to rslike

Rslike is a library that allows avoiding errors with the use of null, undefined, and errors through 2 main classes - Option and Result.

Option<T> - is intended for code that can be null and/or undefined. The Some and None functions allow wrapping a value, and later, where needed, one can return the value using the unwrap and expect functions, or check the presence of a variable value using the isSome, isNone functions.

For convenience, I'll show what will be called not as Option but as Some, or None

Some() // None<undefined>
Some(3) // Some<3>
Some<number>(3) // Some<number>
Some(undefined) //! None<undefined>
Some<number>(undefined) //! None<number>

None() // None<undefined>
None(null) // None<null>
None(3) // None<number>

Enter fullscreen mode Exit fullscreen mode

Result<T,E> - is intended for working with code that can "crash". To avoid unexpected crashes, it is desirable to consider all execution options of the function, or wrap it in Bind to make the function "safe" by returning Result<Option<T>, E>. Where T and E are generics that you can pass to the function.

Ok(3) // Result<3, unknown>
Ok<number>(3) // Result<number, unknown>
Ok(undefined) // Result<undefined, unknown>

Err(undefined) // Result<unkown, undefined>
Err<number>('hello world') // Result<number, string>
Err(new Error('hello world')) // Result<unkown, Error>
Enter fullscreen mode Exit fullscreen mode

Also, thanks to the useful functions Bind and Async, functions and asynchronous code can be made safe, because the result will use double wrapping in Result<Option<T>, E>

import { Bind, Async } from '@rslike/std'
function external(arg1: any, arg2: any): any {
  // some implementation, can throw
}
external(1,2) // ok. e.g. returns 5
external(1,NaN) // throws Error

const binded = Bind(external)
binded(1,2) // Ok(Some(5))
binded(1,NaN) // Err(Error)

const promiseOk = Promise.resolve(5)
const safePromise1 = await Async(promiseOk) // Ok(Some(5))

safePromise1.isErr() // false
safePromise1.isOk() // true

const promiseErr = Promise.reject('I fails unexpectedly')
const safePromise2 = await Async(promiseErr) // Err('I fails unexpectedly')

safePromise2.isErr() // true
safePromise2.isOk() // false

Enter fullscreen mode Exit fullscreen mode

And now to what has been changed.

Std. Well-known Symbols

For convenience of use, many Well-known Symbols had to be implemented.

These symbols include:

  • Symbol.iterator
  • Symbol.asyncIterator
  • Symbol.search
  • Symbol.split
  • Symbol.toPrimitive
  • Symbol.toStringTag
  • Symbol.inspect (yes, I know it's not well-known and is used exclusively for the inspect function in node.js. Why not?)

The simplest example of usage is using the for...of loop.

Before version 3, it was necessary to use unwrap

import { Some } from '@rslike/std'

const a = Some([1,2,3])

for(let el of a.unwrap()){
  // el: 1,2,3
}

Enter fullscreen mode Exit fullscreen mode

Since version 3, syntactic sugar without using unwrap is now available. It's a small thing, but nice 🙃.

import { Some } from '@rslike/std'
const a = Some([1,2,3])

for(let el of a){
  // el: 1,2,3
}

Enter fullscreen mode Exit fullscreen mode

Bonus - TS Type inferring. If the type inside Option or Result is not iterable, it will be never. And also, UndefinedBehaviorError will be thrown at runtime because a number does not have an implementation of Symbol.iterator (this method is specifically called for the wrapped value).

import { Some } from '@rslike/std'

const a = Some(3)
for(let el of a) {
  // el: never
}

Enter fullscreen mode Exit fullscreen mode

STD. instanceof for Some, None, Err, Ok

To avoid importing an unnecessary class for just one instanceof check, Symbol.hasInstance was implemented for the Some, None, OK, and Err functions.

Let's look at an example before version 3.

// v2
import { Ok, Result } from '@rslike/std'
const a = Ok(3)
a instanceof Result // true
a instanceof Ok // false

Enter fullscreen mode Exit fullscreen mode

And now an example after (Result importing is not required).

// v3
import { Ok, Err } from '@rslike/std'
const a = Ok(3)
a instanceof Ok // true
a instanceof Err // false

Enter fullscreen mode Exit fullscreen mode

Yes, it's syntactic sugar to avoid unnecessary imports.

STD. TS types

A separate personal pride - TypeScript and its computed types. Now in Some, None, Ok, Err, not just generics are passed, but constant generics. This allowed for some tricks. And in cases where we cannot determine the type (or it's more general), the previous implementation will be called.

import { Some } from '@rslike/std'

let a: number = 5
const a = Some(a) // Some<number>

a.isSome() // boolean

let b: number = 5
const c = Some(b) // Some<number>

a.isSome() // boolean

Enter fullscreen mode Exit fullscreen mode

But it didn't go without problems. I just can't overcome the issue of mutating the value inside the class. For example, the replace method - should mutate the value (yes, it does mutate). But how to make TypeScript allow mutating types for the class - that's a riddle 🧐. (if you know - write a comment or message me privately, contact me at the end of the article).

STD. match and double unwrap

In the std package, in addition to Option and Result, there are also some utilities, such as Bind, Async, and match. While Bind and Async remain unchanged, the match function, on the contrary, acquired a useful feature - calling unwrap twice for Result<Option>. This allowed the code in the project to be halved using match.

Let's compare how it was before (67 lines)

import { Bind, match, Err, Ok } from '@rslike/std'

function divide(a: number, b: number) {
  if (b === 0) Err("Divide to zero");
  if (a === 0) Ok(0);
  if (Number.isNaN(a) || Number.isNaN(b)) return Err(undefined);
  return a / b;
}

const binded = Bind(divide);
const fn1 = binded(1, 1); // Result<Option<number | undefined>, string>
const fn2 = binded(NaN, 1); // Result<Option<undefined>, string>

const res1 = match(
  fn1, // or fn2
  (res) => {
    return match(
      res,
      (value) => {
        console.log("value is:", value);
      },
      () => {
        console.log("value is None");
      }
    );
    },
    (err) => {
      console.error(err);
    }
);

console.log(res1); // value is: 1
console.log(res2); // value is None

Enter fullscreen mode Exit fullscreen mode

You can notice that the match function is called twice here. It would be simpler to just check for isOk and isSome, and the code would be shorter.

Starting from version 3 (27 lines)

import { Bind, match, Err, Ok } from '@rslike/std'

function divide(a: number, b: number) {
  if (b === 0) Err("Divide to zero");
  if (a === 0) Ok(0);
  if (Number.isNaN(a) || Number.isNaN(b)) return Err(undefined);
  return a / b;
}

const binded = Bind(divide);
const fn1 = binded(1, 1); // Result<Option<number | undefined>, string>
const fn2 = binded(NaN, 1); // Result<Option<undefined>, string>

const res1 = match(
  fn1, // or fn2
  (value) => {
    console.log("value is:", value);
  },
  (err) => {
    if (err) console.error(err);
    else console.log("value is None");
  }
);

res1 // value is: 1
// or res2 - value is None

Enter fullscreen mode Exit fullscreen mode

Cmp

For the package intended for comparison (cmp or comparison package), the methods equals, partialEquals, and compare were removed from the interfaces for Eq, PartialEq, and Ord. Instead, the interfaces require the implementation of Symbol.equals, Symbol.partialEquals, and Symbol.compare, respectively.

import { type Eq, equals } from '@rslike/cmp'

class Author implements Eq {
  constructor(readonly name: string){}

  [Symbol.equals](another: unknown){
    return another instanceof Author && this.name === another.name
  }
}

const pushkin = new Author('Pushkin')
const tolkien = new Author('Tolkien')

pushin[Symbol.equals](tolkien) // false
pushin[Symbol.equals](new Author('Pushkin')) // true

// or you can call the utility function
equals(pushkin, tolkien) // false
equals(pushkin, new Author('Pushkin')) // true

Enter fullscreen mode Exit fullscreen mode

As a bonus, these well-known symbols are defined for the following global objects:

  • Number
  • String
  • Boolean
  • Date

Usage in the Project

Version 3 emerged because I started using my own library at my current workplace. In short, I needed to implement a CLI in Node.js to fetch data from the server and save it to a file. This code resides within the project itself and is atomic and devoid of imports from the project (except for npm libraries). Additionally, I was given complete creative freedom for this task in terms of implementation, approaches, libraries, and coding style. It was also desirable to ensure that the entire code fits into one file and not to inflate the CLI with multiple models, arguments, etc. Said and done.

The file itself fits into 500 lines of formatted code. Helpers (like axios instance, paths, and error code descriptions) take up ~250 lines of formatted code. So, a total of ~250 lines of logic. There are 5 commands:

  • login
  • logout
  • ls - to list all environment variables on the server
  • get - to fetch info from the server based on parameters and write to a file
  • ctl - similar to get but instead of writing to a file, it passes the information obtained from the server as an argument to the command provided. For example, program ctl 'npm run tests'. The env information will be passed to tests.

I tried rewriting this file without using rslike and ended up with ~20% more code because this code is mostly checks for null, undefined, and try catch finally. For example, whether an argument was passed or not. Therefore, I consider my library quite successful.

In Conclusion

In conclusion, I would like to add that the release itself turned out to be quite significant in terms of the number of changes.

massive update

Due to this, the output bundle could not help but increase. If you look at bundlephobia, you can notice its "voracity". Unlike version 2, of course, JSDoc forms the basis, which has increased due to examples and the exceptions that will be thrown, as well as more complex TS typing to make it easier and more convenient for end users!

8.3kB v2 & 12.6kB v3

Contacts

As promised — my contact information.

rslike in GitHub — https://github.com/vitalics/rslike

Github — https://github.com/vitalics

Telegram — https://t.me/vitalicset

Top comments (0)