microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.55k stars 12.43k forks source link

Support some non-structural (nominal) type matching #202

Open iislucas opened 10 years ago

iislucas commented 10 years ago

Proposal: support non-structural typing (e.g. new user-defined base-types, or some form of basic nominal typing). This allows programmer to have more refined types supporting frequently used idioms such as:

1) Indexes that come from different tables. Because all indexes are strings (or numbers), it's easy to use the an index variable (intended for one table) with another index variable intended for a different table. Because indexes are the same type, no error is given. If we have abstract index classes this would be fixed.

2) Certain classes of functions (e.g. callbacks) can be important to be distinguished even though they have the same type. e.g. "() => void" often captures a side-effect producing function. Sometimes you want to control which ones are put into an event handler. Currently there's no way to type-check them.

3) Consider having 2 different interfaces that have different optional parameters but the same required one. In typescript you will not get a compiler error when you provide one but need the other. Sometimes this is ok, but very often this is very not ok and you would love to have a compiler error rather than be confused at run-time.

Proposal (with all type-Error-lines removed!):

// Define FooTable and FooIndex
nominal FooIndex = string;  // Proposed new kind of nominal declaration.
interface FooTable {
  [i: FooIndex]: { foo: number };
}
let s1: FooIndex;
let t1: FooTable;

// Define BarTable and BarIndex
nominal BarIndex = string; // Proposed new kind of nominal declaration.
interface BarTable {
  [i: BarIndex]: { bar: string };
}
let s2: BarIndex;
let t2: BarTable;

// For assignment from base-types and basic structures: no type-overloading is needed.
s1 = 'foo1';
t1 = {};
t1[s1] = { foo: 1 };

s2 = 'bar1';
t2 = { 'bar1': { bar: 'barbar' }};

console.log(s2 = s1); // Proposed to be type error.
console.log(s2 == s1); // Proposed to be type error.
console.log(s2 === s1); // Proposed to be type error.

t1[s2].foo = 100; // Gives a runtime error. Proposed to be type error.
t1[s1].foo = 100;

function BadFooTest(t: FooTable) {
  if (s2 in t) {  // Proposed to be type error.
    console.log('cool');
    console.log(t[s2].foo); // Proposed to be type error.
  }
}

function GoodBarTest(t: BarTable) {
  if (s2 in t) {
    console.log('cool');
    console.log(t[s2].bar);
  }
}

BadFooTest(t1); // Gives runtime error;
BadFooTest(t2); // No runtime error, Proposed to be type error.
GoodBarTest(t1); // Gives runtime error; Proposed to be type error.
GoodBarTest(t2);
kube commented 7 years ago

@SimonMeskens

In fact I created a dedicated issue, but it was closed as a duplicate:

https://github.com/Microsoft/TypeScript/issues/17344

RyanCavanaugh commented 7 years ago

Collecting use cases from this thread

phiresky commented 6 years ago

It seems like since https://github.com/Microsoft/TypeScript/pull/15473 was merged, another solution to this would be to use

type MyNominalType = unique symbol & { memberA: string, memberB: number }

But this fails with [ts] "unique symbol"-types are not allowed here.

aluanhaddad commented 6 years ago

@phiresky unique symbol types are associated with constant values. The following will work

declare const A: unique symbol;
declare const B: unique symbol;

type MyNominalType1 = typeof A & { memberA: string, memberB: number };
type MyNominalType2 = typeof B & { memberA: string, memberB: number };

declare let x: MyNominalType1;
declare let y: MyNominalType2;

x = y; // assignability error
y = x; // assignability error

The above is essentially the equivalent to the --declaration emitted for

const A = Symbol();
const B = Symbol();
type MyNominalType1 = typeof A & { memberA: string, memberB: number };
type MyNominalType2 = typeof B & { memberA: string, memberB: number };
ghost commented 6 years ago

@phiresky @aluanhaddad I like this solution better than type brands. :+1:

typeofweb commented 6 years ago

Is there any chance we could make it work? This looks like a legitemate usecase:

type A = unique symbol // currently error TS1335: 'unique symbol' types are not allowed here.

or

type MyNominalType1 = (unique symbol) & { memberA: string, memberB: number };
Ciantic commented 6 years ago

I noticed this article: https://codemix.com/opaque-types-in-javascript it mentions the flow syntax for opaque types:

// @flow
export opaque type PaymentAmount = number;

Is there a chance similar syntax is feasible in TypeScript?

aaronjensen commented 6 years ago

Building on this a little more...

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

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

type Email = Opaque<string, 'email'>
type CustomerId = Opaque<string, 'customerId'>
type Money = Opaque<number, 'money'>
type Id = Opaque<number, 'id'>

let email = <Email>'abc.com';
let customerId = <CustomerId>'asdfasdfasdf';

// fails
email = customerId

let Money = <Money>35.45;
let id = <Id>7;

// fails
money = id

try

demurgos commented 6 years ago

@aaronjensen I really like this idea: works great. My main issue is that if this code is duplicated in different modules, it creates a new incompatible OpaqueTag. It may be what you want if you can ensure uniqueness of the modules. When publishing my libraries to npm, I still want them to work with duplication but require an explicit key.

For this use case, you can simply use:

type Named<T, S extends string> = T & {__tsTag?: S}; // Use (string | symbol) to eventually allow symbol names
import { Named as Named1 } from "lib1";
import { Named as Named2 } from "lib2";

type Lib1Email = Named1<string, "email">;
type Lib2Email = Named2<string, "email">;
type Lib1CustomerId = Named1<string, "customerId">;

let email1 = "foo@bar" as Lib1Email;
let email2 = "foo@baz" as Lib2Email;
let customer1 = "111" as Lib1CustomerId;

email1 = email2; // OK
email1 = customer1; // Fail
aaronjensen commented 6 years ago

@demurgos You may want to drop the ? on __tsTag unless you want to be able to do let email: Lib1Email = "foo@bar" w/o the cast. Also, because it's not private, email1.__tsTag typechecks. Trade offs...

aaronjensen commented 6 years ago

And yeah, I'd say that usually when you have opaque types you want them to be opaque to other modules. That's how flow does it. If two modules both declare an Email string type you may not actually want them to be compatible because they may not be. If they are, then they could share a common module that defines the opaque type once.

demurgos commented 6 years ago

@aaronjensen Thank you, you are right that the ? makes two of my named strings incompatible if they don't have the same tag but not with the base type. I wanted to avoid unneeded constraints but in this case you need it.

You are right that opaque types should be unique, but I don't think it is the best solution for Node's ecosystem because relying on uniqueness across multiple packages is not reliable (that's probably one of the reasons why TS is structural first). Again, it is fine if you can guarantee uniqueness but it depends on the use case.

The main problem I want to solve is to prevent assigning a string with CustomerId semantics to a string with Email semantics, but I prefer the uniqueness of Email to not depend on the uniqueness of the module defining it (even if it guarantees that there is no accidental confusion). With my solution, my idea was to let the developer handle the uniqueness: if he creates two types with the same tag, assume they have the same semantics. Of course this has the trade-off that there may be two types Named<string, "id"> for example with different semantics that are now still conflicting. I'd still say that it improves the situation if you use meaningful names by reducing accidents (there's less Named<string, "id"> with different semantics than string with different semantics). You can still namespace the tags such as Named<string, "demurgos/email"> if you want. On the other hand, it does work well with duplication and npm.

Here is a more concrete example. The pg package is a lib to use Postgres from Node, the squel library is a library to build SQL queries. Currently pg has a Client interface with .query(sqlText: string, values: any[]): Promise<QueryResult> and squel returns string values. If they wanted it, they could both use type Sql = Named<string, "sql">; instead of string. You'd have to manually tag a string with as Sql but both of them would happily inter-operate.

I never really tried to implement this kind of tagging across multiple packages before. I think that now that you gave me the idea, I'll try and see if it's worth it or not.

aaronjensen commented 6 years ago

@demurgos Sounds good. Both opaque types and named types as you've described I'm sure have their uses, but they're two different things. Good luck!

mindplay-dk commented 6 years ago

Is this happening? I'm getting desperate.

SimonMeskens commented 6 years ago

@mindplay-dk You want something like this. Doesn't create any ghost properties, doesn't create any run-time properties:

const InputConnectorIdentity = Symbol("InputConnector");
const OutputConnectorIdentity = Symbol("OutputConnector");

type GraphNode = {
    inputs: InputConnector[]
    outputs: OutputConnector[]
}

const createNode = (): GraphNode => ({
    inputs: [],
    outputs: []
});

type Connector = {
    node: GraphNode
};

type InputConnector = typeof InputConnectorIdentity & Connector;
type OutputConnector = typeof OutputConnectorIdentity & Connector;

const createConnector = (node: GraphNode) => ({
    node: node
});

const createInputConnector:
    (node: GraphNode) => InputConnector =
    <any>createConnector;

const createOutputConnector:
    (node: GraphNode) => OutputConnector =
    <any>createConnector;

let node1 = createNode();
let node2 = createNode();

let input = createInputConnector(node2);
let output = createOutputConnector(node2);

node1.inputs.push(input); // works
node1.inputs.push(output); // error: Argument of type 'OutputConnector' is not assignable to parameter of type 'InputConnector'
aaronjensen commented 6 years ago

That's great @SimonMeskens, thanks for posting.

Unless I'm mistaken, the Symbol creations will not get dead code eliminated by UglifyJS unless pure_funcs=Symbol is included, or if those symbols are created in a file that only exports types. In that case, typescript would probably strip them out, but I haven't tested it.

SimonMeskens commented 6 years ago

If you want to strip them out, this should work. It's basically equivalent, but with declare it's compile-time only.

declare const InputConnectorIdentity: unique symbol
declare const OutputConnectorIdentity: unique symbol
aaronjensen commented 6 years ago

Ah, that's even nicer, thanks.

wesleyolis commented 6 years ago

How about introduction a new type checking modifier operator, that is implemented for type checking only. Which would allow the best of both worlds, nominal an structural typing paradigms.

As far as I know '$' is not used for specially many yet, so how about allowing $ donate use nominal type checking for the following use cases.

I would prefer to have everything swapped around, but that could break alot of backwards compatibility, mabye it could be debated to have a global compiler and localised ts file setting

Use Cases

nominal variants

assignment:

$=

call signature only:

(index :$ {index :number} => void

call signature and return:

(index :$ {index :number} $=> void

return:

(index : {index :number} $=> void

type, implies nominal should also be used were , unless explicitly cast away..

type test $= testing;

variable, implies nominal should also be used were , unless explicitly cast away..

const var1 $= 'test'; let var1 $= 'test'; readonly var1 $= 'test';

structural

assignment:

=

call signature only:

(index : {index :number} => void

call signature and return:

(index : {index :number} => void

return:

(index : {index :number} => void

type

test = testing;

variable

const var1 = 'test'; let var1 = 'test'; readonly var1 = 'test';

Casting away nominal type checking

For each nominal declare type their is an additional generated structure type name. The same behaviour as for classes, were their is a generated interface.. The $ identify must always be used in a type definition and assignments, so that it is explicitly obvious as to how type checking is being performed.

type nTypeA $= {a : string}; type nTypeB $= {a : string}; type TypeA = $ or $typeof NTypeNA

const varNa :$ nTypeStringA = {a:'test'} const varNb :$ nTypeStringB = {a:'test'}

const varNa : typeof nTypeA = varNa; //OK allow casting const varNb : typeof nTypeA = VarNb; //OK

const varNa : nTypeB = varNa; //OK allow casting const varNb : nTypeA = VarNb; //OK

const varA :$ nTypeB = Na; // Error nominal type checking const varB :$ nTypeA = Nb; // Error nominal type checking

const varNa : TypeA = varNb; //OK const varNb : TypeA = VarNa; //OK

fenduru commented 6 years ago

I tried experimenting with modifying the TS compiler a couple months ago, but I got stuck due to a lack compiler domain knowledge. However I feel like the unique keyword gives us exactly what is needed for nominal typing. Today it is restricted to Symbols, however from my (admittedly naive) understanding of the code, it seems like this limitation is not a fundamental one (there are simply checks to ensure you're only using the keyword before a symbol).

Are there actual limitations from allowing users to write:

type NominalNumber = unique number;
simonbuchan commented 6 years ago

The fact that it doesn't make that much sense at runtime, if I had to guess. Symbols are special in that you get a new one every time (unless you're using .for()), and the use of unique symbol is restricted to those locations where the compiler can assume the runtime value won't change. I suppose you could make a case for id generators retuning unique number or string? But that meaning isn't really related to this issue.

In any case, being able to declare a type as being unique (ie nominal) isn't really the problem, it's that it's not clear how you should be able to use such a type, and what benefit does it give?

fenduru commented 6 years ago

@simonbuchan ID generators are one of the most common uses, and the one I'm particularly interested, so I'm not sure what you mean by

it's not clear how you should be able to use such a type, and what benefit does it give?

Construction of the nominal type would need to either special case variable definitions (const foo: NominalString = 'string') or users would need to explicitly cast (though that has the unfortunate downside of being unsafe). I'd personally be happy to take the downside of explicit casting, as it would allow me to "be careful" in one place, rather than all callsites.

I currently accomplish this with code along the lines of:

type NominalStringA = string & { __nominal?: unique symbol };
type NominalStringB = string & { __nominal?: unique symbol };

let a: NominalStringA = 'a';
let b: NominalStringB = 'b';
a = b; // error

This kind of works, as it at least prevents me from passing one nominal type where another is expected. It would let you pass a string to a function(x: NominalStringA) which is why assignment works, but it is at least better than nothing. All of my nominal types also need to use the same __nominal key as having that key's type conflict is how the approach works. (In practice I actually use another unique symbol as the key so __nominal doesn't show up in my editor completion)

simonbuchan commented 6 years ago

I mean what operations should be permitted by such a type? Can you do a + a? a.trim()? Is that still NominalStringA, or is it string? console.log(a)? Can you a.concat(a)? a.concat('foo')? Can you assign a non-literal string to a nominal string?

More importantly by far, is your answer to each of these questions going to be something generally useful, or only the specific case of ID generators? Will adding this block adding a more general solution later? In which situations does it prevent bugs? Where does it hide bugs? (e.g. casting) And so on...

This is just for the most simple case of nominal primitive types - when you get nominal object types (nominal function types?) things can get much weirder, since you have questions of how subtyping works, including variance.

spion commented 6 years ago

FYI since nominal types would be a bit of a problem with node_module style scoped lookups, I settled with branded types

https://goo.gl/3Jjtp4

function branded<T, Brand>() {
    return class Type {
        private value: Type;
        private '__ kind': Brand;
        static toBranded(t: T) { return t as any as Type; }
        static unbrand(b: Type) { return b as any as T }
        static Type: Type;
    }
}

let OrderId = branded<string, 'OrderId'>()
type OrderId = typeof OrderId.Type;

let x = OrderId.toBranded('someUuid')
let y = 'someString';
let b1 = y == x; // generates error
let b2 = y == OrderId.unbrand(x)

let m: Map<OrderId, string> = new Map;

m.set(y, '1')
m.set(x, '2')
simonbuchan commented 6 years ago

That's a box, not a brand, since it allocates a new object, but sure, you can do that too.

dead-claudia commented 6 years ago

My primary use cases fall into one of two categories:

  1. I have a type whose entire structure, including typeof, is opaque and an implementation detail. This comes up a little more frequently for me than for many, since my programs are usually a little more data-driven. For one concrete example, this module packs all its data into a single 32-bit integer. I can't possibly type it except as a nominal opaque type that happens to support >/</===/etc.

  2. I return what's effectively just an ID. I do that pervasively in these two modules, with IDs orchestrated via this module. I also frequently do it in other scenarios where data handling is critical.

dead-claudia commented 6 years ago

@simonbuchan Only during type instantiation. The toBranded and unbrand methods are the important ones here, not the constructor itself.

simonbuchan commented 6 years ago

Yes, but that's what a box is :) Java and C# both do autoboxing (slightly differently), but you can box a primitive (or other type) manually too.

As mentioned earlier in this thread, opaque types seem like they would satisfy many of these suggestions, if done TS should probably do that compatible with the way flow does it, but:

dead-claudia commented 6 years ago

@spion @simonbuchan [I made it a bit lower-memory](http://www.typescriptlang.org/play/#src=interface%20Branded%3CT%2C%20Type%3E%20%7B%0D%0A%20%20%20%20'__%20to%20brand'(t%3A%20T)%3A%20Type%3B%0D%0A%20%20%20%20'__%20from%20brand'(b%3A%20Type)%3A%20T%3B%0D%0A%20%20%20%20T%3A%20T%3B%0D%0A%20%20%20%20Type%3A%20Type%3B%0D%0A%7D%0D%0A%0D%0Afunction%20toBranded%3CT%2C%20Type%3E(b%3A%20Branded%3CT%2C%20Type%3E%2C%20value%3A%20T)%20%7B%0D%0A%20%20%20%20return%20value%20as%20any%20as%20Type%3B%0D%0A%7D%0D%0A%0D%0Afunction%20fromBranded%3CT%2C%20Type%3E(b%3A%20Branded%3CT%2C%20Type%3E%2C%20branded%3A%20Type)%20%7B%0D%0A%20%20%20%20return%20branded%20as%20any%20as%20T%3B%0D%0A%7D%0D%0A%0D%0Afunction%20branded%3CT%2C%20Brand%3E()%20%7B%0D%0A%20%20%20%20interface%20Type%20%7B%20value%3A%20Type%3B%20'__%20kind'%3A%20Brand%3B%20%7D%0D%0A%20%20%20%20return%20undefined%20as%20Branded%3CT%2C%20Type%3E%3B%0D%0A%7D%0D%0A%0D%0A%0D%0Alet%20OrderId%20%3D%20branded%3Cstring%2C%20'OrderId'%3E()%0D%0Atype%20OrderId%20%3D%20typeof%20OrderId.Type%3B%0D%0A%0D%0Alet%20x%20%3D%20toBranded(OrderId%2C%20'someUuid')%0D%0Alet%20y%20%3D%20'someString'%3B%0D%0Alet%20b1%20%3D%20y%20%3D%3D%20x%3B%0D%0Alet%20b2%20%3D%20y%20%3D%3D%20fromBranded(OrderId%2C%20x)%0D%0A%0D%0Alet%20m%3A%20Map%3COrderId%2C%20string%3E%20%3D%20new%20Map%3B%0D%0A%0D%0Am.set(y%2C%20'1')%0D%0Am.set(x%2C%20'2')%0D%0A), so it doesn't require any runtime allocation at all (it's trivially inlinable).

Edit: muffed up the link.

dead-claudia commented 6 years ago

@simonbuchan There's no "autoboxing" involved. Type instantiation is analogous to class instantiation here, not value instantiation.

I do want something similar to what Flow has, either way. As long as I can unsafely cast it into its component type (or at least safely cast it within the same file), I'm happy.

simonbuchan commented 6 years ago

Yes, that's now no longer boxing, but your types are a bit messed up, claiming it has a T property with the value, which it doesn't. There's many many many many, suggestions for branding types in this thread!

dead-claudia commented 6 years ago

@simonbuchan I'm not afraid to lie at the type level for better static verification, and I've done plenty of times in TypeScript. I've yet to see a workaround here for adding sufficiently "opaque" types that doesn't require lying about types to some extent. This even includes @spion's workaround which fails to implement static type: Type correctly.

As long as you know not to actually try to use it at the value level, you should be good. (I've yet to run into a case where it became a real problem, and it's not especially common to want to store values with their brands.)

simonbuchan commented 6 years ago

Sure, but since you're casting anyway, you don't need the value property, since it doesn't actually use it.

dead-claudia commented 6 years ago

@simonbuchan [True, for the invariants it checks](http://www.typescriptlang.org/play/#src=interface%20Branded%3CT%2C%20Type%3E%20%7B%0D%0A%20%20%20%20'__%20to%20brand'(t%3A%20T)%3A%20Type%3B%0D%0A%20%20%20%20'__%20from%20brand'(b%3A%20Type)%3A%20T%3B%0D%0A%20%20%20%20T%3A%20T%3B%0D%0A%20%20%20%20Type%3A%20Type%3B%0D%0A%7D%0D%0A%0D%0Afunction%20toBranded%3CT%2C%20Type%3E(b%3A%20Branded%3CT%2C%20Type%3E%2C%20value%3A%20T)%20%7B%0D%0A%20%20%20%20return%20value%20as%20any%20as%20Type%3B%0D%0A%7D%0D%0A%0D%0Afunction%20fromBranded%3CT%2C%20Type%3E(b%3A%20Branded%3CT%2C%20Type%3E%2C%20branded%3A%20Type)%20%7B%0D%0A%20%20%20%20return%20branded%20as%20any%20as%20T%3B%0D%0A%7D%0D%0A%0D%0Afunction%20branded%3CT%2C%20Brand%3E()%20%7B%0D%0A%20%20%20%20interface%20Type%20%7B%20'__%20kind'%3A%20Brand%3B%20%7D%0D%0A%20%20%20%20return%20undefined%20as%20Branded%3CT%2C%20Type%3E%3B%0D%0A%7D%0D%0A%0D%0A%0D%0Alet%20OrderId%20%3D%20branded%3Cstring%2C%20'OrderId'%3E()%0D%0Atype%20OrderId%20%3D%20typeof%20OrderId.Type%3B%0D%0A%0D%0Alet%20x%20%3D%20toBranded(OrderId%2C%20'someUuid')%0D%0Alet%20y%20%3D%20'someString'%3B%0D%0Alet%20b1%20%3D%20y%20%3D%3D%20x%3B%0D%0Alet%20b2%20%3D%20y%20%3D%3D%20fromBranded(OrderId%2C%20x)%0D%0A%0D%0Alet%20m%3A%20Map%3COrderId%2C%20string%3E%20%3D%20new%20Map%3B%0D%0A%0D%0Am.set(y%2C%20'1')%0D%0Am.set(x%2C%20'2')%0D%0A). Neither does the [original version](http://www.typescriptlang.org/play/#src=%0D%0A%0D%0Afunction%20branded%3CT%2C%20Brand%3E()%20%7B%0D%0A%20%20%20%20return%20class%20Type%20%7B%0D%0A%20%20%20%20%20%20%20%20private%20'__%20kind'%3A%20Brand%3B%0D%0A%20%20%20%20%20%20%20%20static%20toBranded(t%3A%20T)%20%7B%20return%20t%20as%20any%20as%20Type%3B%20%7D%0D%0A%20%20%20%20%20%20%20%20static%20fromBranded(b%3A%20Type)%20%7B%20return%20b%20as%20any%20as%20T%20%7D%0D%0A%20%20%20%20%20%20%20%20static%20Type%3A%20Type%3B%0D%0A%20%20%20%20%7D%0D%0A%7D%0D%0A%0D%0A%0D%0Alet%20OrderId%20%3D%20branded%3Cstring%2C%20'OrderId'%3E()%0D%0Atype%20OrderId%20%3D%20typeof%20OrderId.Type%3B%0D%0A%0D%0Alet%20x%20%3D%20OrderId.toBranded('someUuid')%0D%0Alet%20y%20%3D%20'someString'%3B%0D%0Alet%20b1%20%3D%20y%20%3D%3D%20x%3B%0D%0Alet%20b2%20%3D%20y%20%3D%3D%20OrderId.fromBranded(x)%0D%0A%0D%0Alet%20m%3A%20Map%3COrderId%2C%20string%3E%20%3D%20new%20Map%3B%0D%0A%0D%0Am.set(y%2C%20'1')%0D%0Am.set(x%2C%20'2')%0D%0A), however.

spion commented 6 years ago

If you look at it carefully, it is in fact a brand and not a box. The class stuff is there mainly to hide the machinery using private (e.g. the type has no visible .value property)

nafg commented 6 years ago

cf. scala opaque type aliases, I think

https://docs.scala-lang.org/sips/opaque-types.html

dead-claudia commented 6 years ago

@nafg I like that concept. That's a good idea: internally treat them as aliases, but export them as opaque nominal types.

mohsen1 commented 6 years ago

why instead of marking type declarations nominal you're not considering making type assignment and assertions nominal with a new syntax?

type A = {}
type B = {}

var a := A = {};
var b := B = {};

a = b; // Error

var aa : A = {};
var bb : B = {};
aa = bb // OK

Alternative syntax:

var a : nominal A = {}
dead-claudia commented 6 years ago

@mohsen1 The whole point of nominal typing is to force users to stray away from relying on implementation details, not to enable them to avoid it by convention (which is already possible by just not accessing certain properties).

ForbesLindesay commented 6 years ago

I recently built https://github.com/ForbesLindesay/opaque-types, which may be of interest to people here. It's a transpiler that generates opaque types (using various hacks involving symbols and declared classes with private properties) from simple type aliases marked with comments/annotations.

Jamesernator commented 6 years ago

While nominal interface/type/etc are quite the can of worms, I don't see why nominal classes (by default) wouldn't be desirable. I can't think of a case where using a class that just happens to be structurally the same as another would ever be acceptable.

e.g.:

// The author is obviously only expecting an inline span element
// passing a different element may break styles, etc
function addToEnd(text: HTMLSpanElement) {
  someElement.appendChild(text)
}

const el = document.createElement('div')
el.textContent = 'Some extra text'
// Probably a mistake, will definitely break styling given it's typically
// a block element rather than inline
// Even though it's structurally identical to HTMLSpanElement why allow it?
// Classes have brands for a reason
addToEnd(el)

And sure you can work around it with hacks like the unique symbol thing, but I think it would be better if TypeScript captured the existing nominal-ness of classes out of the box rather than depending on hacks to work around TypeScript's inability to understand existing nominal constructs.


Personal Note: I'm still torn between using Flow and TypeScript for this reason, unfortunately neither is a complete solution now that TypeScript has variadic types (or at least enough to type zip(...iterables) and similar functions).

Jamesernator commented 6 years ago

Looking into it more, it seems that TypeScript already does understand nominal classes (to some extent I haven't investigated in detail).

Try this example in the playground, TypeScript correctly infers in the if branch that randomElement is an HTMLDivElement so at least some nominal type information is already there: https://www.typescriptlang.org/play/#src=%0D%0Aconst%20randomElement%3A%20HTMLSpanElement%20%7C%20HTMLDivElement%0D%0A%20%20%20%20%3D%20Math.random()%20%3E%200.5%0D%0A%20%20%20%20%20%20%20%20%3F%20document.createElement('span')%0D%0A%20%20%20%20%20%20%20%20%3A%20document.createElement('div')%0D%0A%0D%0Aif%20(randomElement%20instanceof%20HTMLDivElement)%20%7B%0D%0A%20%20%20%20%2F%2F%20TypeScript%20has%20already%20inferred%20randomElement%20is%20an%20HTMLDivElement%0D%0A%20%20%20%20%2F%2F%20yet%20we%20can't%20use%20this%20information%20to%20make%20this%20assignment%20invalid%0D%0A%20%20%20%20const%20x%3A%20HTMLSpanElement%20%3D%20randomElement%0D%0A%7D

agos commented 6 years ago

@Jamesernator I would say that is not nominal, typing, but the type narrowing effect of having an instanceof type guard in an if condition - relevant TS documentation

akomm commented 6 years ago

It is a bit sad, that unlike in flow you don't have a distinction of class/interface being like common in OOP nominal, and object literals / type being structural.

If you had the concept, adding something like "opaque type" to "type" to make it behave nominal outside the module it is defined in would be a relative small step to do.

// opaque-types.ts
export opaque type FooId = string;
export opaque type BarId = string;
export function getUniqueFooId(): FooId {
  return '(not-really)-unique-foo-id'; // inside same module it behaves structural
}
// opaque-types-consumer.ts
// import [...]
const x: FooId = getUniqueFooId();
const y: BarId = x; // error, nominal type FooId is not compatible with nominal type BarId
damncabbage commented 6 years ago

Where it might suck is if you have two versions of the same library hanging around in the dependency tree. Say if you have foo library v1.1 and v1.2; if you have a FooId from one, should you be able to give it to the other and still have it type-check? What about between foo v1 and v3?

As it is, though, we have private-using classes as prior art, and I think in those cases the type would not be able to be passed between them (without a cast). Ditto for opaque types with Flow. This can be totally valid as a choice! It just is a consideration and something to be decided on deliberately.

(I would really like cheap/easy nominal types; I use them alongside structural types day-to-day in other languages, and they're enormously useful.)

zpdDG4gta8XKpMCd commented 6 years ago

UPDATED: i will edit it while i can, TLDR: nominal types are not better than structural, they are not a silver bullet that we should pick by default, they have their scope of applicability, so do structural types, a balanced solution would be having them both, just as for any other dilemma that is sought to be solved by one way over another

ORIGINAL RANT:

@akomm so are you sad because typescript goes a bit beyond OOP that you know and love? structural typing is a blessing sent to us by great gods of computer science, the opaque types you are asking about is merely an inconvenience (promised to be solved) that hasn't been a problem since the time of July, 2014

i remember my days of c# where 2 identical interfaces were incompatible and i had to "convert" one into another writing a "XyzConverter" and all accompanying bs like "XyzConverterAbstractFactoryServiceProvider"

don't get me wrong, any design decision is a double edge sword, and that's where my point comes up: it can't be that nominal interfaces by default are "better" than structural, neither is "better"

akomm commented 6 years ago

@damncabbage I am not sure if I understood you correct. But having a dependency tree with same package in two versions sounds very odd. You talk about resolving those via npm/yarn or something you do by hand? Because in case of npm/yarn there is no such thing as version namespacing. You have only one version of a package as far as I know.

simonbuchan commented 6 years ago

@akomm, he's referring to getting the same package from two different dependency paths, for example using a glmatrix library directly and using a rendering library that happens to use and expose the same library, which is quite plausible.

The problem is though, that this is actually something that this glmatrix library might want to avoid you doing: perhaps it has internal flags that change between versions, or it has some sort of allocation pool that would break things if there were multiple instances.

To me this is an argument for nominal types (to be available for the library to use, not required), not against.

akomm commented 6 years ago

@simonbuchan I understand that there might be different pathes to same dependency, but as far as I know the version is picked that match both, if no matching for both is found you get error installing packages. IN fact you have almost always have same dependencies over different pathes, by how tiny npm packages are fragmented. There are many generic out there that are used frequently. Still at the end you have 1 package in 1 version. Except someone creates a fork package which you can add as dependency in different version. But then there should be no problem as they have separate namespace.

This is why I wanted to clarify what he actually means is a problem.

simonbuchan commented 6 years ago

Only if the version ranges match and both can be hoisted to a common path, and can even depend on the order they were added. It's not at all unusual for the same package to be duplicated dozens of times.