Open iislucas opened 10 years ago
@SimonMeskens
In fact I created a dedicated issue, but it was closed as a duplicate:
Collecting use cases from this thread
Customer
vs Client
but both are People
, our own brands on various Node
kinds)CustomerID
and OrderID
are both number
but should never interchangeIt 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
.
@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 };
@phiresky @aluanhaddad I like this solution better than type brands. :+1:
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 };
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?
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
@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
@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...
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.
@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.
@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!
Is this happening? I'm getting desperate.
@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'
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.
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
Ah, that's even nicer, thanks.
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
$=
(index :$ {index :number} => void
(index :$ {index :number} $=> void
(index : {index :number} $=> void
type test $= testing;
const var1 $= 'test'; let var1 $= 'test'; readonly var1 $= 'test';
=
(index : {index :number} => void
(index : {index :number} => void
(index : {index :number} => void
test = testing;
const var1 = 'test'; let var1 = 'test'; readonly var1 = 'test';
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 = $
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
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;
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?
@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)
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.
FYI since nominal types would be a bit of a problem with node_module style scoped lookups, I settled with branded types
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')
That's a box, not a brand, since it allocates a new object, but sure, you can do that too.
My primary use cases fall into one of two categories:
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.
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.
@simonbuchan Only during type instantiation. The toBranded
and unbrand
methods are the important ones here, not the constructor itself.
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:
Edit: muffed up the link.
@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.
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!
@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.)
Sure, but since you're casting anyway, you don't need the value property, since it doesn't actually use it.
@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.
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)
cf. scala opaque type aliases, I think
@nafg I like that concept. That's a good idea: internally treat them as aliases, but export them as opaque nominal types.
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 = {}
@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).
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.
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).
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
@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
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
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.)
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"
@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.
@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.
@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.
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.
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!):