12

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

Lu4
  • 14,873
  • 15
  • 79
  • 132
  • The very few nominal-like capabilities of TypeScript do seem to require that you write something twice to get two different names. Once you collapse `Discriminator` to a type alias, all references to it will refer to the same thing. So I can't imagine how to do what you're trying to do. – jcalz May 19 '19 at 01:22
  • 2
    Also, you might consider using [`unique symbol`](https://github.com/Microsoft/TypeScript/wiki/What's-new-in-TypeScript#unique-symbol) instead of the weird `any extends infer O ? ...` conditional type. Unique symbols are intended to be nominal-like, whereas I have no idea if the non-mutual-assignability of `any extends infer O ? ...` can be expected to persist in future versions of TypeScript. – jcalz May 19 '19 at 01:24
  • Is there another typed language that lets you dynamically/programmatically create nominal types in the manner you're asking for? I'm just wondering if there is any precedent for this anywhere. – jcalz May 19 '19 at 18:19
  • I don't think it's about the nominal types themselves, but rather how strict is type checking for the language, I think Haskel would allow to do something like that... – Lu4 May 19 '19 at 23:53
  • I'm not sure I understand what you mean by "strict". Haskell is nominal, so you can create incompatible types by using different names (e.g., via `newtype`) but as far as I know you can't create two incompatible types by using a single name/notation. – jcalz May 20 '19 at 00:08

1 Answers1

1

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
Tomasz Gawel
  • 8,379
  • 4
  • 36
  • 61