traverse1984 / oxide.ts

Rust's Option<T> and Result<T, E>, implemented for TypeScript.
MIT License
517 stars 19 forks source link

Unexpected TypeScript behavior with Option, Some and None #23

Open Kolobamanacas opened 2 months ago

Kolobamanacas commented 2 months ago

To fully reproduce the issue, you could set up tiny Node.js project using the following code:

package.json:

{
  "scripts": {
    "start": "tsc && node app.js"
  },
  "devDependencies": {
    "@types/node": "22.5.4",
    "typescript": "5.5.4"
  },
  "dependencies": {
    "oxide.ts": "1.1.0"
  }
}

tsconfig.json:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "commonjs",
    "sourceMap": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "esModuleInterop": true,
  }
}

app.ts:

import { None, Option } from 'oxide.ts'

export enum Currency {
  EUR = 'EUR',
  UAH = 'UAH',
  USD = 'USD',
}

export interface GenericClientInterface {
  execute(address: string): Promise<void>
}

export interface SpecificClientInterface extends GenericClientInterface {
  lookup(account: string): Promise<boolean>
}

export type CurrencyClientMap = {
  [Currency.EUR]: Option<GenericClientInterface>
  [Currency.UAH]: Option<SpecificClientInterface>
  [Currency.USD]: typeof None
}

export interface CurrencyFactoryInterface {
  build<C extends Currency>(currency: C): CurrencyClientMap[C]
}

export class MyClass{
  public constructor(
    private readonly currencyClientFactory: CurrencyFactoryInterface,
  ) {}

  public execute(currency: Currency): void {
    const clientResult = this.currencyClientFactory.build(currency)

    if (clientResult.isNone()) { // <-- TypeScript error here.
      console.log('clientResult is None')
    }

    const client = clientResult.unwrap()

    console.log('Client: ', client)
  }
}

Actual result: If you set up a project the way described above, you'll notice that in line 35 of app.ts, there is a TypeScript error:

The 'this' context of type 'Option<GenericClientInterface> | Option<SpecificClientInterface> | Readonly<OptionType<never>>' is not assignable to method's 'this' of type 'Option<GenericClientInterface> & Option<SpecificClientInterface> & Option<never>'.
  Type 'Option<GenericClientInterface>' is not assignable to type 'Option<GenericClientInterface> & Option<SpecificClientInterface> & Option<never>'.ts(2684)

This behavior seems wrong as all returned values are either options or None and all of them have .isNone() method.

Expected result: The returned value of Option<GenericClientInterface> | Option<SpecificClientInterface> | typeof None type should not trigger any TypeScript errors.

traverse1984 commented 2 months ago

The issue is that None is Option<never>, so TypeScript is trying to be helpful by warning you of mismatched this contexts.

To make this work you first need to widen the type of None to be an option. The example below hopefully indicates a suitable workaround and doesn't require any assertions:


type T = { test: true };

type Q = {
 one: Option<T>;
 two: Option<T>;
 three: None;
};

const q: Q = null as any;
const qk: keyof Q = null as any;

q[qk].isNone(); // Error

// No need to make an assertion, as None is assignable to Option<T>
const x: Option<T> = q[qk];

x.isNone(); // No complaints
Kolobamanacas commented 2 months ago

Thanks for the workaround, yet I don't quite follow how the example reproduces described case. In your example, typeof None in type Q is replaced with None. This will break any actual map that will try to implement the Q (or CurrencyClientMap in my example).

// This will work.

type CurrencyClientMap = {
  [Currency.USD]: typeof None
}

const currencyClientMap: CurrencyClientMap = {
  [Currency.USD]: None
}
// This will not work.

type CurrencyClientMap = {
  [Currency.USD]: None
}

const currencyClientMap: CurrencyClientMap = {
  [Currency.USD]: None // Error: Type of computed property's value is 'Readonly<OptionType<never>>', which is not assignable to type 'None'.
}

Also, it seems like the issue is not in None itself, but rather in the fact that A | B logic is for some reason expected to be A & B logic. In the original example, if one leaves any two options in Currency enum and CurrencyClientMap type respectively (like [Currency.EUR]: Option<GenericClientInterface> and [Currency.UAH]: Option<SpecificClientInterface> or [Currency.EUR]: Option<GenericClientInterface> and [Currency.USD]: typeof None), the issue remains.

Lastly, even if I fix this in my local project with any kind of workaround, it still seems like an issue with types in Oxide package and needs to be fixed.