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

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

Open
MarcusCemes opened this issue Aug 6, 2023 · 1 comment
Open

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

MarcusCemes opened this issue Aug 6, 2023 · 1 comment

Comments

@MarcusCemes
Copy link

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
Copy link
Author

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.

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

1 participant