mobily / ts-belt

🔧 Fast, modern, and practical utility library for FP in TypeScript.
https://mobily.github.io/ts-belt
MIT License
1.08k stars 30 forks source link

Unsafe `isNone()` and `isSome()` type predicates #85

Open MarcusCemes opened 1 year ago

MarcusCemes commented 1 year ago

Example

The following code is deemed type-safe by TypeScript:

// Without the IIFE, Typescript restricts the type to null, even with an explicit type
const user = ((): { name: string } | null => null)();

if (O.isSome(user)) {
    console.log(user.name);
}

However, it will throw at runtime. The IIFE simulates a Prisma select query, which returns a nullable value.

Explanation

None is defined as either null or undefined:

https://github.com/mobily/ts-belt/blob/c825d9709de1e5d15b1b362429e0cee56c96c516/src/Option/index.ts#L3C1-L6C47

However, the generated code for the isSome() and isNone() type guards only checks for the undefined case:

function  isSome(t) {
  return void 0 !== t;
}

function isNone(t) {
  return void 0 === t;
}

While this code is faster, it assumes that the Option was constructed using the library's constructors, such as O.fromNullable(), which converts null to undefined:

function fromNullable(n) {
  if (null == n) {
    return;
  } else {
    return m(n);
  }
}

Unlike Result, Option is just a union with primitive types. TypeScript will implicitly cast a User | null to an Option<User>, which is an alias for User | null | undefined, allowing it to be used as the parameter of the isSome and isNone functions, causing the runtime error.

Solution

It's possible to use isNull() or isObject() in the guards module instead, however, isSome() and isNone() are still unsafe type predicates. They accept null values but do not check them (for the reasons stated above). I understand that they shouldn't be used in those contexts, but they can be used, and I'm worried that a colleague will make the same mistake and cause unexpected runtime errors that are difficult to track down that could easily have been avoided by checking for null and a negligible performance impact...

Personally, I would recommend removing undefined from the None type. null has a semantic value meaning "nothing", while undefined signifies more than the parameter or variable has not been set. This does not have a performance impact, and undefined can be removed from Some. I'm not familiar with Rescript, but unfortunately, it seems to me that undefined is hardcoded as the TS equivalent of None.

type Some<A> = NonNullable<T>;
type None = null;
type Option<A> = Some<A> | None;
MarcusCemes commented 1 year ago

As an extension to the above, most functions in the Option module will cause runtime errors when null is involved, which is often the case with Prisma. For example, the following code:

function getUserWithToken(token: string): Promise<O.Option<User>> {
    return prisma.user.findUnique({ where: { token } }); // returns "Promise<User | null>"
}

is valid TypeScript. However, when operating on the Option type (using O.map()):

const maybeUser = await getUserWithToken("token")'
const maybeName = O.map(user => user.name);

causes a runtime error TypeError: Cannot read properties of null (reading 'name'). Again, TypeScript assumes that null is a valid type for None, however, ts-belt makes the assumption that an Option can only be undefined.