Search code examples
javascripttypescript

Unexpected TypeScript behavior with oxide.ts


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)
  }
}

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)

Why is it happening? All three returned types have .isNone() method. Why A | B | C is expected to be A & B & C in the value returned by .build() method? Is it a TypeScript or Oxide.ts issue?


Solution

  • The issue is in oxide types. Oxide is Rust's Option and Result<T, E>, implemented for TypeScript, unfortunately it does not play nice with TS type system.

    Let's look at the definitions

    export const T = Symbol("T");
    export const Val = Symbol("Val")
    export type Option<T> = OptionType<T>;
    class OptionType<T> {
       readonly [T]: boolean;
       readonly [Val]: T;
    
       constructor(val: T, some: boolean) {
          this[T] = some;
          this[Val] = val;
       }
    
       isSome(this: Option<T>): this is Some<T> {
          return this[T];
       }
    
       isNone(this: Option<T>): this is None {
          return !this[T];
       }
    }
    export type Some<T> = OptionType<T> & { [T]: true };
    export type None = OptionType<never> & { [T]: false };
    export const None = Object.freeze(
       new OptionType<never>(undefined as never, false)
    );
    

    Firsty, does not take full advantage of TS union types and defines None as OptionType<never>. Option as an union of Some and None is much cleaner solution in TS.

    Secondly, it defines OptionType as class and uses it as a this parameter. Unfortunately it breaks if it is called on a variable which is an union of different types.

    isNone(this: Option<T>): this is None {
        return !this[T];
    }
    

    In call

    const clientResult = this.currencyClientFactory.build(currency);
    
    • clientResult has type None | Option<GenericClientInterface> | Option<SpecificClientInterface>.
    • When calling clientResult.isNone() TS needs to infer the type of this. Thus it transforms the union to intersection: Option<never> & Option<GenericClientInterface> & Option<SpecificClientInterface>. Unfortunately never is not assignable to anything, so TS complains.

    For the call to work, it should be defined as:

    isNone(this: Option<T>): this is None {
      return !this[T];
    }
    

    Given the knowledge above, you can:

    Option 0: reconsider using this library

    Option 1: submit a pull request

    Option 2: work around these issues

    function isNone<T>(o: Option<T>) {
      return o.isNone();
    }
    
    function unwrap<T>(o: Option<T>): T {
      return o.unwrap();
    }
    
    export class MyClass{
      public constructor(
        private readonly currencyClientFactory: CurrencyFactoryInterface,
      ) {}
    
      public execute(currency: Currency): void {
        const clientResult = this.currencyClientFactory.build(currency)
    
        // Now it works
        if (isNone(clientResult)) {
          console.log('clientResult is None')
        } else {
          // Now it works
          const client = unwrap(clientResult);
          console.log('Client: ', client)
        }
      }
    }
    

    Code on TS Playground