New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tag types #4895

Open
aleksey-bykov opened this Issue Sep 21, 2015 · 48 comments

Comments

Projects
None yet
@aleksey-bykov

aleksey-bykov commented Sep 21, 2015

Problem

  • There is no straight way to encode a predicate (some checking) about a value in its type.

Details

There are situations when a value has to pass some sort of check/validation prior to being used. For example: a min/max functions can only operate on a non-empty array so there must be a check if a given array has any elements. If we pass a plain array that might as well be empty, then we need to account for such case inside the min/max functions, by doing one of the following:

  • crashing
  • returning undefined
  • returning a given default value

This way the calling side has to deal with the consequences of min/max being called yet not being able to deliver.

function min<a>(values: a[], isGreater: (one: a, another: a) => boolean) : a {
   if (values.length < 1) { throw Error('Array is empty.'); }
   // ...
}
var found;
try {
   found = min([]);
} catch (e) {
   found = undefined;
}

Solution

A better idea is to leverage the type system to rule out a possibility of the min function being called with an empty array. In order to do so we might consider so called tag types.

A tag type is a qualifier type that indicates that some predicate about a value it is associated with holds true.

const enum AsNonEmpty {} // a tag type that encodes that a check for being non-empty was passed

function min<a>(values: a[] & AsNonEmpty) : a {
   // only values that are tagged with AsNonEmpty can be passed to this function
   // the type system is responsible for enforcing this constraint
   // a guarantee by the type system that only non-empty array will be passed makes
   // implementation of this function simpler because only one main case needs be considered
   // leaving empty-case situations outside of this function
}

min([]); // <-- compile error, tag is missing, the argument is not known to be non-empty
min([1, 2]); // <-- compile error, tag is missing again, the argument is not know to be non-empty

it's up to the developer in what circumstances an array gets its AsNonEmpty tag, which can be something like:

// guarntee is given by the server, so we always trust the data that comes from it
interface GuaranteedByServer {
    values: number[] & AsNonEmpty;
}

Also tags can be assigned at runtime:

function asNonEmpty(values: a[]) : (a[] & AsNonEmpty) | void {
    return values.length > 0 ? <a[] & AsNonEmpty> : undefined;
}

function isVoid<a>(value: a | void) : value is void {
  return value == null;
}

var values = asNonEmpty(Math.random() > 0.5 ? [1, 2, 3] : []);
if (isVoid(values)) {
   // there are no  elements in the array, so we can't call min
   var niceTry = min(values); // <-- compile error; 
} else {
    var found = min(values); // <-- ok, tag is there, safe to call
}

As was shown in the current version (1.6) an empty const enum type can be used as a marker type (AsNonEmpty in the above example), because

  • enums might not have any members and yet be different from the empty type
  • enums are branded (not assignable to one another)

However enums have their limitations:

  • enum is assignable by numbers
  • enum cannot hold a type parameter
  • enum cannot have members

A few more examples of what tag type can encode:

  • string & AsTrimmed & AsLowerCased & AsAtLeast3CharLong
  • number & AsNonNegative & AsEven
  • date & AsInWinter & AsFirstDayOfMonth

Custom types can also be augmented with tags. This is especially useful when the types are defined outside of the project and developers can't alter them.

  • User & AsHavingClearance

ALSO NOTE: In a way tag types are similar to boolean properties (flags), BUT they get type-erased and carry no rutime overhead whatsoever being a good example of a zero-cost abstraction.

UPDATED:

Also tag types can be used as units of measure in a way:

  • string & AsEmail, string & AsFirstName:
var email = <string & AsEmail> 'aleksey.bykov@yahoo.com';
var firstName = <string & AsFirstName> 'Aleksey';
firstName = email; // <-- compile error
  • number & In<Mhz>, number & In<Px>:
var freq = <number & In<Mhz>> 12000;
var width =   <number & In<Px>> 768;
freq = width; // <-- compile error

function divide<a, b>(left: number & In<a>, right: number & In<b> & AsNonZero) : number & In<Ratio<a, b>> {
     return <number & In<Ratio<a, b>>> left  / right;
}

var ratio = divide(freq, width); // <-- number & In<Ratio<Mhz, Px>>
@mhegazy

This comment has been minimized.

Contributor

mhegazy commented Sep 22, 2015

A few thoughts, in the typescript compiler we have used brands to achieve a similar behavior, see: https://github.com/Microsoft/TypeScript/blob/master/src/compiler/types.ts#L485; #1003 could make creating tags a little bit cleaner. A nominal type would be far cleaner solution (#202).

@mhegazy mhegazy added the Discussion label Sep 22, 2015

@aleksey-bykov

This comment has been minimized.

aleksey-bykov commented Sep 24, 2015

one more use case just came across:

var scheduled = setTimeout(function() { }, 1000);
clearInterval(scheduled); // <-- runtime bug

with type tag this situation could have been avoided

declare function setTimeout(act: () => void, delay: number) : number & AsForTimeout;
declare function clearInterval(scheduled: number & AsForInterval);
var scheduled = setTimeout(function() { }, 1000);
clearInterval(scheduled); // <-- compile error
@jovdb

This comment has been minimized.

jovdb commented Oct 5, 2016

With Typescript 2 you can now simulate the behavior you want:

declare class MinValue<T extends number> {
    private __minValue: T;
}

// MinValue type guard
function hasMinValue<T extends number>(value: number, minValue: T): value is number & MinValue<T> {
    return value >= minValue;
}

// Use it
function delay(fn: Function, milliSeconds: number & MinValue<0>) { }

const delayInMs = 200;
delay(() => { }, delayInMs); // error: number not assignable to MinValue<0>

if (hasMinValue(delayInMs, 0)) {
    delay(() => { }, delayInMs); // OK
}

if (hasMinValue(delayInMs, 100)) {
    delay(() => { }, delayInMs); // error: MinValue<100> not assignable to MinValue<0>
}

with this concept, you can also create Ranges:

// MaxValue 
declare class MaxValue<T extends number> {
    private __maxValue: T;
}

// MaxValue type guard
function hasMaxValue<T extends number>(value: number, maxValue: T): value is number & MaxValue<T> {
    return value <= maxValue;
}

// RangeValue
type RangeValue<TMin extends number, TMax extends number> = MinValue<TMin> & MaxValue<TMax>; 

// RangeValue type guard
function inRange<TMin extends number, TMax extends number>(value: number, minValue: TMin, maxValue: TMax): value is number & RangeValue<TMin, TMax> {
    return value >= minValue && value >= maxValue;
}

// Range example
//-----------------
type Opacity = RangeValue<0, 1>;
function setTransparency(opacity: number & Opacity) {
   // ...
}


const opacity = 0.3;
setTransparency(opacity); // error: 'number' not assignable to MinValue<0>

if (inRange(opacity, 0, 1)) { 
    setTransparency(opacity); // OK
}

if (inRange(opacity, 0, 3)) { 
    setTransparency(opacity); // error: MinValue<0> & MaxValue<3> not assignable to MinValue<0> & MaxValue<1>
}
@mindplay-dk

This comment has been minimized.

mindplay-dk commented Apr 7, 2018

IMO this feature is long over due - mainly because there are so many types of strings: UUID, e-mail address, hex color-code and not least entity-specific IDs, which would be incredibly useful when coupling entities to repositories, etc.

I currently use this work-around:

export type UUID = string & { __UUID: undefined }

This works "okay" in terms of type-checking, but leads to confusing error-messages:

Type 'string' is not assignable to type 'UUID'.
  Type 'string' is not assignable to type '{ __UUID: void; }'.

The much bigger problem is, these types aren't permitted in maps, because an index signature parameter type cannot be a union type - so this isn't valid:

interface ContentCache {
    [content_uuid: UUID]: string;
}

Is there a better work-around I don't know about? I've tried something like declare type UUID extends string, which isn't permitted, and declare class UUID extends String {}, which isn't permitted as keys in a map either.

Is there a proposal or another feature in the works that will improve on this situation?

@mindplay-dk

This comment has been minimized.

mindplay-dk commented Apr 15, 2018

I am struggling hard with this in a model I've been building this past week.

The model is a graph, and nodes have input and output connectors - these connectors are identical in shape, and therefore inevitably can be assigned to each other, which is hugely problematic, since the distinction between inputs and outputs (for example when connecting them) is crucial, despite their identical shapes.

I have tried work-arounds, including a discriminator type: "input" | "output" or distinct type "tags" like __Input: void and __Output: void respectively.

Both approaches leave an unnecessary run-time footprint in the form of an extra property, which will never be used at run-time - it exists solely to satisfy the compiler.

I've also attempt to simply "lie" about the existence of a discriminator or "tag" property to satisfy the compiler, since I'll never look for these at run-time anyway - that works, but it's pretty misleading to someone who doesn't know this codebase, who might think that they can use these properties to implement a run-time type-check, since that's literally what the declaration seem to say.

In addition, I have a ton of different UUID key types in this model, which, presently, I only model as strings, for the same reason - which means there's absolutely no guarantee that I won't accidentally use the wrong kind of key with the wrong collection/map etc.

I really hope there's a plan to address this in the future.

@ethanfrey

This comment has been minimized.

ethanfrey commented Apr 27, 2018

Great issue. Added two thoughts here:

The original usecase from @aleksey-bykov sounds a lot like dependent-types, implemented most famously in Idris, where you can define a type "array of n positive integers", and a function that appends two arrays and returns a third array of (n+m) positive integers. Non-empty is a simple case of that. If you like this power, take a look at https://www.idris-lang.org/example/

The simpler usecase from @mindplay-dk is actually what I am struggling with right now. In my example, I have Uint8Array, which may be a PrivateKey or a PublicKey for a cryptographic hash function and I don't want to mix them up. Just like the input and output nodes. This was my solution...

interface IPrivateKey extends Uint8Array {
  readonly assertPrivateKey: undefined;
}

function asPrivate(bin: Uint8Array): IPrivateKey {
  // tslint:disable-next-line:prefer-object-spread
  return Object.assign(bin, { assertPrivateKey: undefined });
}

function signMessage(msg: string, secret: IPrivateKey): Uint8Array {
 // ...
}

Same for public key. When I just did type IPublicKey = Uint8Array, tsc would treat them interchangeably.

I would like to see a bit longer example of how you use the UUID type, but it sounds quite similar to my approach in principle. Maybe there is a better way to deal with this. The Object.assign ugliness bothers me and adds runtime overhead.

@dgreensp

This comment has been minimized.

dgreensp commented Apr 29, 2018

@ethanfrey How about:

function asPrivate(bin: Uint8Array): IPrivateKey {
  return bin as IPrivateKey
}
@mhegazy

This comment has been minimized.

Contributor

mhegazy commented Jun 20, 2018

how about string & { [sym]: typeof uniqueSymbol }

@aleksey-bykov

This comment has been minimized.

aleksey-bykov commented Jun 21, 2018

i like the following better:

declare class As<Tag extends string> { private 't a g': Tag; }
type UserId = string & As<'user-id'>
@SlurpTheo

This comment has been minimized.

SlurpTheo commented Jun 21, 2018

mhegazy commented 18 hours ago
how about string & { [sym]: typeof uniqueSymbol }

Whoa... I got lost. Why is this { [a: string]: ... }-wrapping of typeof uniqueSymbol necessary for string & but not for something like MyClass &?

@SimonMeskens

This comment has been minimized.

SimonMeskens commented Jun 21, 2018

@SlurpTheo you're mistaking computed properties with indexers. And none of this is "necessary", just multiple options

chuan6 added a commit to teambition/teambition-sdk that referenced this issue Jun 24, 2018

refactor(typings): 改善和纠正各种 id 类型的性质
各种 id 类型如:UserId, ProjectId, TaskId 等等,它们应该有下列性质:

 1.可以赋给 string 类型(允许如:`const x: string = id as UserId`)
 2.相互之间不可赋值(如遇 `const tid: TeamId = uid as UserId` 会报错,
   需要手动强转)
 3.string 不可以赋给它们(如遇 `const id: UserId = 'hello'` 会报错,需
   要手动强转)

原来 `interface X extends String { kind?: 'X' }` 的实现,满足了2,但没
有满足1、3。

不满足1,导致当需要将 id 数据从带有场景上下文的业务代码传给不关心业务逻
辑而只是简单接受 string 的底层组件时,需要通过 `as string` 强转,如果
该信息包在一个对象结构里,那这个对象结构要么需要 `as any`,结果丢失所
有类型信息,要么底层组件的对应对象结构类型声明就需要添加类型参数(泛型
声明),结果增加冗长而意义不大的泛型声明。

而不满足3,会漏掉很多类型检查,因为并不是任何 string 类型的值都可以赋
值给特定 id 类型的。

新的写法是:

  `type X = string & { kind: 'X' }`

它能同时满足1、2、3。

参考:

 - https://codemix.com/opaque-types-in-javascript/
 - Microsoft/TypeScript#15807
 - Microsoft/TypeScript#4895
 - Microsoft/TypeScript#202
 - https://github.com/Microsoft/TypeScript/blob/d9b93903c035e48c8da1d731332787f83efc4619/src/compiler/types.ts#L54

chuan6 added a commit to teambition/teambition-sdk that referenced this issue Jun 25, 2018

refactor(typings): 改善和纠正各种 id 类型的性质
各种 id 类型如:UserId, ProjectId, TaskId 等等,它们应该有下列性质:

 1.可以赋给 string 类型(允许如:`const x: string = id as UserId`)
 2.相互之间不可赋值(如遇 `const tid: TeamId = uid as UserId` 会报错,
   需要手动强转)
 3.string 不可以赋给它们(如遇 `const id: UserId = 'hello'` 会报错,需
   要手动强转)

原来 `interface X extends String { kind?: 'X' }` 的实现,满足了2,但没
有满足1、3。

不满足1,导致当需要将 id 数据从带有场景上下文的业务代码传给不关心业务逻
辑而只是简单接受 string 的底层组件时,需要通过 `as string` 强转,如果
该信息包在一个对象结构里,那这个对象结构要么需要 `as any`,结果丢失所
有类型信息,要么底层组件的对应对象结构类型声明就需要添加类型参数(泛型
声明),结果增加冗长而意义不大的泛型声明。

而不满足3,会漏掉很多类型检查,因为并不是任何 string 类型的值都可以赋
值给特定 id 类型的。

新的写法是:

  `type X = string & { kind: 'X' }`

它能同时满足1、2、3。

参考:

 - https://codemix.com/opaque-types-in-javascript/
 - Microsoft/TypeScript#15807
 - Microsoft/TypeScript#4895
 - Microsoft/TypeScript#202
 - https://github.com/Microsoft/TypeScript/blob/d9b93903c035e48c8da1d731332787f83efc4619/src/compiler/types.ts#L54

chuan6 added a commit to teambition/teambition-sdk that referenced this issue Jun 27, 2018

refactor(typings): 改善和纠正各种 id 类型的性质
各种 id 类型如:UserId, ProjectId, TaskId 等等,它们应该有下列性质:

 1.可以赋给 string 类型(允许如:`const x: string = id as UserId`)
 2.相互之间不可赋值(如遇 `const tid: TeamId = uid as UserId` 会报错,
   需要手动强转)
 3.string 不可以赋给它们(如遇 `const id: UserId = 'hello'` 会报错,需
   要手动强转)

原来 `interface X extends String { kind?: 'X' }` 的实现,满足了2,但没
有满足1、3。

不满足1,导致当需要将 id 数据从带有场景上下文的业务代码传给不关心业务逻
辑而只是简单接受 string 的底层组件时,需要通过 `as string` 强转,如果
该信息包在一个对象结构里,那这个对象结构要么需要 `as any`,结果丢失所
有类型信息,要么底层组件的对应对象结构类型声明就需要添加类型参数(泛型
声明),结果增加冗长而意义不大的泛型声明。

而不满足3,会漏掉很多类型检查,因为并不是任何 string 类型的值都可以赋
值给特定 id 类型的。

新的写法是:

  `type X = string & { kind: 'X' }`

它能同时满足1、2、3。

参考:

 - https://codemix.com/opaque-types-in-javascript/
 - Microsoft/TypeScript#15807
 - Microsoft/TypeScript#4895
 - Microsoft/TypeScript#202
 - https://github.com/Microsoft/TypeScript/blob/d9b93903c035e48c8da1d731332787f83efc4619/src/compiler/types.ts#L54

chuan6 added a commit to teambition/teambition-sdk that referenced this issue Jun 27, 2018

refactor(typings): 改善和纠正各种 id 类型的性质
各种 id 类型如:UserId, ProjectId, TaskId 等等,它们应该有下列性质:

 1.可以赋给 string 类型(允许如:`const x: string = id as UserId`)
 2.相互之间不可赋值(如遇 `const tid: TeamId = uid as UserId` 会报错,
   需要手动强转)
 3.string 不可以赋给它们(如遇 `const id: UserId = 'hello'` 会报错,需
   要手动强转)
 4.可以直接使用在对象 index 的位置

原来 `interface X extends String { kind?: 'X' }` 的实现,满足了2,但没
有满足1、3、4。

不满足1,导致当需要将 id 数据从带有场景上下文的业务代码传给不关心业务逻
辑而只是简单接受 string 的底层组件时,需要通过 `as string` 强转,如果
该信息包在一个对象结构里,那这个对象结构要么需要 `as any`,结果丢失所
有类型信息,要么底层组件的对应对象结构类型声明就需要添加类型参数(泛型
声明),结果增加冗长而意义不大的泛型声明。

不满足3,会漏掉很多类型检查,因为并不是任何 string 类型的值都可以赋
值给特定 id 类型的。

不满足4,有时通过把 id 作为 key,构建简易的字典对象时,就不得不麻烦地
在 [] 里写 `as string`。

新的写法是:

  `type X = string & { kind: 'X' }`

它能同时满足1、2、3、4。

参考:

 - https://codemix.com/opaque-types-in-javascript/
 - Microsoft/TypeScript#15807
 - Microsoft/TypeScript#4895
 - Microsoft/TypeScript#202
 - https://github.com/Microsoft/TypeScript/blob/d9b93903c035e48c8da1d731332787f83efc4619/src/compiler/types.ts#L54

chuan6 added a commit to teambition/teambition-sdk that referenced this issue Jun 28, 2018

refactor(typings): 改善和纠正各种 id 类型的性质
各种 id 类型如:UserId, ProjectId, TaskId 等等,它们应该有下列性质:

 1.可以赋给 string 类型(允许如:`const x: string = id as UserId`)
 2.相互之间不可赋值(如遇 `const tid: TeamId = uid as UserId` 会报错,
   需要手动强转)
 3.string 不可以赋给它们(如遇 `const id: UserId = 'hello'` 会报错,需
   要手动强转)
 4.可以直接使用在对象 index 的位置

原来 `interface X extends String { kind?: 'X' }` 的实现,满足了2,但没
有满足1、3、4。

不满足1,导致当需要将 id 数据从带有场景上下文的业务代码传给不关心业务逻
辑而只是简单接受 string 的底层组件时,需要通过 `as string` 强转,如果
该信息包在一个对象结构里,那这个对象结构要么需要 `as any`,结果丢失所
有类型信息,要么底层组件的对应对象结构类型声明就需要添加类型参数(泛型
声明),结果增加冗长而意义不大的泛型声明。

不满足3,会漏掉很多类型检查,因为并不是任何 string 类型的值都可以赋
值给特定 id 类型的。

不满足4,有时通过把 id 作为 key,构建简易的字典对象时,就不得不麻烦地
在 [] 里写 `as string`。

新的写法是:

  `type X = string & { kind: 'X' }`

它能同时满足1、2、3、4。

参考:

 - https://codemix.com/opaque-types-in-javascript/
 - Microsoft/TypeScript#15807
 - Microsoft/TypeScript#4895
 - Microsoft/TypeScript#202
 - https://github.com/Microsoft/TypeScript/blob/d9b93903c035e48c8da1d731332787f83efc4619/src/compiler/types.ts#L54
@ethanfrey

This comment has been minimized.

ethanfrey commented Jun 28, 2018

Since Tyepscript 2.9, these above constructs no longer work, at least the issue with
string & typeof uniqueSymbol as that is converted to a never type....

This is documented in #25268 and considered desired behavior by the typescript team. Any ideas on how to build tag types that still work with the stricter type rules?

@agos

This comment has been minimized.

agos commented Jun 28, 2018

@ethanfrey I use this, found on a related issue thread (sorry but I can't find the original)

declare class OpaqueTag<S extends string> {
  private tag: S;
}

type Opaque<T, S extends string> = T & OpaqueTag<S>;

type UserUUID = Opaque<string, 'UserUUID'>;
type TransactionId = Opaque<string, 'TransactionId'>;

const a: UserUUID = '...' as UserUUID; // assigning a string literal requires a cast
const b: TransactionId = UserUUID // errors

It's not perfect, but in my experience gets the job done and it's really readable

@SimonMeskens

This comment has been minimized.

SimonMeskens commented Jun 28, 2018

@agos the problem with that solution is that it's not type safe. Symbols are the only nominal type, so you need a solution that uses them. That much is clear.

Here's the sample adapted to be type-safe:

// Using namespace here simply to show that external files 
// should NOT have access to OpaqueTagSymbol or OpaqueTag. 
// Put this in its own module, without the namespace
namespace Tag {
    declare const OpaqueTagSymbol: unique symbol;
    declare class OpaqueTag<S extends symbol> {
        private [OpaqueTagSymbol]: S;
    }

    export type Opaque<T, S extends symbol> = T & OpaqueTag<S>;
}

// Simple alias, if in a module, same as:
// import { Opaque } from "tagmodule";
type Opaque<T, S extends symbol> = Tag.Opaque<T, S>;

// Since these are ghost symbols, you probably don't want to export them
// Create actual symbols if you do
declare const UserUUIDSymbol: unique symbol;
declare const TransactionId: unique symbol;

type UserUUID = Tag.Opaque<string, typeof UserUUIDSymbol>;
type TransactionId = Opaque<string, typeof TransactionId>;

const a: UserUUID = '...' as UserUUID; // assigning a string literal requires a cast
const b: TransactionId = a // errors
@ethanfrey

This comment has been minimized.

ethanfrey commented Jun 28, 2018

Nice solution, thank you @SimonMeskens

@agos

This comment has been minimized.

agos commented Jun 29, 2018

@SimonMeskens it seems to me that the only case where the type safety of the solution I posted earlier would be that two Tag types with the same string passed as second type parameter? In that case it might be still a worthy tradeoff

@sledorze

This comment has been minimized.

sledorze commented Jun 29, 2018

@SimonMeskens that's true but it limits the solution to where Symbol is supported.
I hope that argument will vanish soon. :)

@SimonMeskens

This comment has been minimized.

SimonMeskens commented Jun 29, 2018

@sledorze I didn't use symbol at all, it's all just in declarative type land, so this solution will work even in environments without symbol :)

Magic! (of course, if you have to target an old version it might not work)

@agos That's correct, it's mostly a collision problem, but there's also the issue that with strings, you can't encapsulate creation, as any other part of the code can construct a valid one by providing the same string. With symbols, if the symbol isn't exported, only that part of the code is able to create that specific tagged type.

@sledorze

This comment has been minimized.

sledorze commented Jun 29, 2018

@SimonMeskens Oh yes! wonderful ;)

@ProdigySim

This comment has been minimized.

ProdigySim commented Jul 23, 2018

I liked @SimonMeskens example, but it leaves the string prototype methods available on the resulting type--which I did not like.

I made the following change which seems to preserve some some checks for the original type:

export type Opaque<T, S extends symbol> = T & OpaqueTag<S> | OpaqueTag<S>;

By adding the intersection, Typescript will not let such a variable be used as a string without a type assertion.

For example:

type UserId = Opaque<string, UserIdSymbol>;

const userId: UserId = "123" as UserId;

userId.replace('3', '2'); // This works on the Union type, but not the union+intersected 

function takesAString(str: string) {
  // ...
}

takesAString(userId); // This works on the Union type, but not the union+intersected
takesAString(userId as string); // This works on both versions

const num: number = userId as number; // this doesn't work on either version

I'm sure there's probably a better way to implement this, and certainly room for either set of semantics.

@ForbesLindesay

This comment has been minimized.

ForbesLindesay commented Jul 26, 2018

I built https://github.com/ForbesLindesay/opaque-types which uses a transpiler to support opaque and nominal types, with optional runtime validation for the cast operation. It may be of interest to people here.

@qm3ster

This comment has been minimized.

qm3ster commented Aug 5, 2018

@ForbesLindesay way to go!
What do you think about https://github.com/gcanti/newtype-ts?

@ForbesLindesay

This comment has been minimized.

ForbesLindesay commented Aug 8, 2018

@qm3ster that looks pretty cool. The iso and prism functions are a neat approach to avoiding runtime cost for multiple opaque types in a single project. The main issue I see with it is that you've used functional terminology, instead of plain english words. You've also tightly coupled your solution to functional programming concepts like Options, which are not commonly used in JavaScript. The other small points are that you have _URI and _A show up as properties on your opaque values, when in fact those properties do not exist at runtime.

The main advantage I see to using my transpiled approach, is that it's very easy to completely change, in the event that new versions of typescript break the current approach. The secondary advantage is that I get to use clean syntax, and ensure that the type name is the same as the name of the object that provides utility functions for cast/extract.

I mainly wanted to prototype how I thought they might behave if added to the language.

@qm3ster

This comment has been minimized.

qm3ster commented Aug 13, 2018

@ForbesLindesay It's not my project :v I just used it a few times.
I see how the notation might be an impedance to wider adoption.
It's positioning itself as part of a larger gcanti/fp-ts ecosystem, hence the Option type.

@qwerty2501

This comment has been minimized.

qwerty2501 commented Sep 1, 2018

@ProdigySim @SimonMeskens Nice solution but it seems to has some cons as below.

  • The notation declare const symbolName:unique symbol is long. And therefore, the user should write multi line when define opaque type alias.
  • The symbolName in declare const symbolName:unique symbol is necessary not. However the user should define symbolName in user's namespace.

Then I tried improvement. And it seems working.
Please let me know if there are problems.

The definition of Opaque:

interface SourceTag{
    readonly tag:symbol;
}

declare const OpaqueTagSymbol: unique symbol;

declare class OpaqueTag<S extends SourceTag>{
    private [OpaqueTagSymbol]:S;
}

export type Opaque<T,S extends SourceTag> = T & OpaqueTag<S> | OpaqueTag<S>;

usage:

type UserId = Opaque<string,{ readonly tag:unique symbol}>;

type UserId2 = Opaque<string,{ readonly tag:unique symbol}>;

const userId:UserId = 'test' as UserId ;

const userId2:UserId2 = userId; // compile error

The notation Opaque<string,{ readonly tag:unique symbol}> can be written in one line.

@ProdigySim

This comment has been minimized.

ProdigySim commented Sep 25, 2018

I tried out that approach and it's definitely a shorter syntax, but I think most of the time I would not be too worried about one extra line since I will create relatively few opaque types, and I will probably add other boilerplate/helpers in the type's module.

One difference between the two approaches is the error message we get from typescript:

From @SimonMeskens 's setup:

[ts]
Type 'Opaque<string, typeof EmailSymbol>' is not assignable to type 'Opaque<string, typeof UserIdSymbol>'.
  Type 'Opaque<string, unique symbol>' is not assignable to type 'OpaqueTag<unique symbol>'.
    Types of property '[OpaqueTagSymbol]' are incompatible.
      Type 'typeof EmailSymbol' is not assignable to type 'typeof UserIdSymbol'.

From @qwerty2501 's setup:

Type 'Opaque<string, { readonly tag: typeof tag; }>' is not assignable to type 'Opaque<string, { readonly tag: typeof tag; }>'. Two different types with this name exist, but they are unrelated.

I stripped the namespace from both errors to make them more equivalent. The latter is shorter, but the former explicitly calls out Email vs UserId.

@qwerty2501

This comment has been minimized.

qwerty2501 commented Sep 26, 2018

@ProdigySim
True. I think it is trade off between "easy to understand error" and "the syntax is shorter".

@ProdigySim

This comment has been minimized.

ProdigySim commented Sep 27, 2018

I checked out how Flow handles opaque types in comparison to our solutions. They have some interesting behavior.

Notably:

  1. Opaque types are treated differently in the file they're created in. Implicit conversions from underlying-type to Opaque Type are allowed in the same file the type is created in.
  2. The Opaque Types behave similarly to @SimonMeskens 's original solution (string & { [TagSym]: typeof UserIdSymbol }). That is, implicit conversions TO the underlying type are allowed.
  3. Opaque Types can be used as index types. e.g. { [idx: UserId]: any } is a valid type. This is not currently possible in typescript afaict.

I put together a typescript playground link demonstrating different constructions of Opaque<T> and their capabilities.

  1. "Super Opaque": OpaqueTag<S>-- no reference to underlying type, no valid casts to an underlying type.
  2. "Weak Opaque": T & OpaqueTag<S> -- matches flow behavior closely, automatic downcasts to T
  3. "Strong Opaque": T & OpqaueTag<S> | OpaqueTag<S> -- keeps some reference to underlying type for explicit conversions, but doesn't allow implicit casting.

I think each could have uses; but a first-party Typescript solution could definitely allow the best of all worlds here.

@weswigham weswigham referenced this issue Oct 16, 2018

Open

JSON type #27930

4 of 4 tasks complete
@MicahZoltu

This comment has been minimized.

Contributor

MicahZoltu commented Nov 2, 2018

For others coming across this, a fairly succinct solution that results in short but readable error messages can be found over here. Comes with caveats, since it is setup to ignore a compiler warning, but so far I like the UX of it the most out of all of the options I have seen so far. Need to test it cross-module still though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment