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