Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Atomic type discrimination (nominal atomic types) in TypeScript

Tags:

typescript

I'm just curios, is there a way to discriminate atomic types for greater type safety in TypeScript?

In other words, is there a way to replicate behavior below:

export type Kilos<T> = T & { discriminator: Kilos<T> };   // or something else  
export type Pounds<T> = T & { discriminator: Pounds<T> }; // or something else

export interface MetricWeight {
    value: Kilos<number>
}

export interface ImperialWeight {
    value: Pounds<number>
}

const wm: MetricWeight = { value: 0 as Kilos<number> }
const wi: ImperialWeight = { value: 0 as Pounds<number> }

wm.value = wi.value;                  // Should give compiler error
wi.value = wi.value * 2;              // Shouldn't error, but it's ok if it would, because it would require type casting which asks for additional attention
wm.value = wi.value * 2;              // Already errors
const we: MetricWeight = { value: 0 } // Already errors

Or something that would allow to put it in one container:

export type Discriminate<T> = ...

export type Kilos<T> = Discriminate<Kilos<T>>;
export type Pounds<T> = Discriminate<Pounds<T>>;

...

Edit

Ok, it turns out it's possible to build such type using impossible type hack discovered by ZpdDG4gta here https://github.com/microsoft/TypeScript/issues/202

But it's a bit messy with current language version:

export type Kilos<T> = T & { discriminator: any extends infer O | any ? O : never };
export type Pounds<T> = T & { discriminator: any extends infer O | any ? O : never };

export interface MetricWeight {
    value: Kilos<number>
}

export interface ImperialWeight {
    value: Pounds<number>
}

const wm: MetricWeight = { value: 0 as Kilos<number> }
const wi: ImperialWeight = { value: 0 as Pounds<number> }

wm.value = wi.value;                       // Errors, good
wi.value = wi.value * 2;                   // Errors, but it's +/- ok
wi.value = wi.value * 2 as Pounds<number>; // Shouldn't error, good
wm.value = wi.value * 2;                   // Errors, good
const we: MetricWeight = { value: 0 }      // Errors, good

Unfortunately the following wouldn't work:

export type Discriminator<T> = T & { discriminator: any extends infer O | any ? O : never } 

export type Kilos<T> = Discriminator<T>;
export type Pounds<T> = Discriminator<T>;

export interface MetricWeight {
    value: Kilos<number>
}

export interface ImperialWeight {
    value: Pounds<number>
}

const wm: MetricWeight = { value: 0 as Kilos<number> }
const wi: ImperialWeight = { value: 0 as Pounds<number> }

wm.value = wi.value;                       // Doesn't error, this is bad
wi.value = wi.value * 2;                   // Errors, but it's +/- ok
wi.value = wi.value * 2 as Pounds<number>; // Shouldn't error, good
wm.value = wi.value * 2;                   // Errors, good
const we: MetricWeight = { value: 0 }      // Errors, good

Edit

It turns out that there is another way to introduce the impossible type, as per @jcalz:

export type Kilos<T> = T & { readonly discriminator: unique symbol };
export type Pounds<T> = T & { readonly discriminator: unique symbol };

...

However there's still an issue with the lack of

export type Discriminator<T> = ...

Any thoughts to make it cleaner? Since type aliasing makes both type references stick to Discriminator...

Edit

Further optimization shown that it's possible to define discriminated type as:

export type Kilos<T> = T & { readonly '': unique symbol };
export type Pounds<T> = T & { readonly '': unique symbol };

Which helps with resolution of IDE's intellisense pollution

like image 459
Lu4 Avatar asked May 18 '19 13:05

Lu4


1 Answers

Just define it like:

const marker = Symbol();

export type Kilos = number & { [marker]?: 'kilos' };
export const Kilos = (value = 0) => value as Kilos;

export type Pounds = number & { [marker]?: 'pounds' };
export const Pounds = (value = 0) => value as Pounds;

Then Pounds and Kilos are auto casted on numbers and from numbers, but not on each others.

let kilos = Kilos(0);
let pounds = Pounds(0);
let wrong: Pounds = Kilos(20); // Error: Type 'Kilos' is not assignable to type 'Pounds'.

kilos = 10; // OK
pounds = 20;  // OK

let kilos2 = 20 as Kilos; // OK
let kilos3: Kilos = 30; // OK

pounds = kilos;  // Error: Type 'Kilos' is not assignable to type 'Pounds'.
kilos = pounds; // Error: Type 'Pounds' is not assignable to type 'Kilos'.

kilos = Kilos(pounds / 2); // OK
pounds = Pounds(kilos * 2); // OK

kilos = Pounds(pounds / 2); // Error: Type 'Pounds' is not assignable to type 'Kilos'.

kilos = pounds / 2; // OK
pounds = kilos * 2; // OK

If you want to prevent auto cast from "enhanced" unit to "plain" number then just remove optional from marker field:

const marker = Symbol();
export type Kilos = number & { [marker]: 'kilos' };
// ------------------------------------^ -?
export const Kilos = (value = 0) => value as Kilos;

// then:
const kilos = Kilos(2); // OK
kilos = 2; // Error
kilos = kilos * 2; // Error
like image 110
Tomasz Gawel Avatar answered Nov 12 '22 21:11

Tomasz Gawel