Skip to content
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

Structural tag type brands #33290

Closed
wants to merge 7 commits into from
Closed

Conversation

weswigham
Copy link
Member

@weswigham weswigham commented Sep 6, 2019

Consider this a competitor to (or at least consideration for) #33038.

This makes explicit the current patterns of structural branding and reserves their functionality with special syntax. The newly introduced syntax is the new keyword type operator tag T, where T can be any type. It is used like so:

type NormalizedPath = string & tag {NormalizedPath: void};
type AbsolutePath = string & tag {AbsolutePath: void};
type NormalizedAbsolutePath = NormalizedPath & AbsolutePath;

declare function isNormalizedPath(x: string): x is NormalizedPath;
declare function isAbsolutePath(x: string): x is AbsolutePath;
declare function consumeNormalizedAbsolutePath(x: NormalizedAbsolutePath): void;


const p = "/a/b/c";
if (isNormalizedPath(p)) {
    if (isAbsolutePath(p)) {
        consumeNormalizedAbsolutePath(p);
    }
}

This PR also now adds a global type Tag<T extends string> = tag {[K in T]: void}; for convenience, which means instead of the above, you can write:

type NormalizedPath = string & Tag<"NormalizedPath">;
type AbsolutePath = string & Tag<"AbsolutePath">;
type NormalizedAbsolutePath = NormalizedPath & AbsolutePath;

which gives a simple way to get a nice string-based pseudo-unique tag.

The operand type does not contribute to the visible structure of the type in any way (a tag still looks like unknown when queried), however tag types are related with one another based on their argument type to ensure tag compatibility.

Compared with nominal tags, this has some upsides:

  • Identical tags across files are assignable to one another, as they are structurally related
  • It works like structural brands do today, but prevents the tags from contributing to the object's visible structure (with respect to completions and the like)
  • It enshrines structural tagging patterns with special syntax (which can then be recognized and optimized)

@weswigham weswigham added the Experiment label Sep 6, 2019
@weswigham weswigham changed the title Structural tag T type brands Structural tag type brands Sep 6, 2019
@weswigham weswigham mentioned this pull request Sep 6, 2019
5 tasks
@microsoft microsoft deleted a comment from typescript-bot Sep 6, 2019
@weswigham
Copy link
Member Author

weswigham commented Sep 6, 2019

@typescript-bot pack this just cuz

@typescript-bot
Copy link
Collaborator

typescript-bot commented Sep 6, 2019

Heya @weswigham, I've started to run the tarball bundle task on this PR at ddf4c06. You can monitor the build here. It should now contribute to this PR's status checks.

@typescript-bot
Copy link
Collaborator

typescript-bot commented Sep 6, 2019

Hey @weswigham, I've packed this into an installable tgz. You can install it for testing by referencing it in your package.json like so:

{
    "devDependencies": {
        "typescript": "https://typescript.visualstudio.com/cf7ac146-d525-443c-b23c-0d58337efebc/_apis/build/builds/43118/artifacts?artifactName=tgz&fileId=8BA8A1D463F3BC3A57FC68726284B560EF87B5AC16CA6D80428C17B792D1BD4A02&fileName=/typescript-3.7.0-insiders.20190906.tgz"
    }
}

and then running npm install.

@microsoft microsoft deleted a comment from typescript-bot Sep 7, 2019
@AnyhowStep
Copy link
Contributor

AnyhowStep commented Sep 7, 2019

If one wanted the same behavior as the other PR (nominally typed, different libraries are incompatible, different versions of same library are incompatible), would it be done like this?

type NormalizedPath = string & tag unique symbol;
type AbsolutePath = string & tag unique symbol;

If so, this PR basically has all the features of the other PR and more.

@weswigham
Copy link
Member Author

weswigham commented Sep 7, 2019

If one wanted the same behavior as the other PR (nominally typed, different libraries are incompatible, different versions of same library are incompatible), would it be done like this?

Not quite - unique symbol has to appear directly on a declaration, so it'd need to look like so:

declare var normalizedSym: unique symbol;
type NormalizedPathBrand = tag { [normalizedSym]: void; };
type NormalizedPath = string & NormalizedPathBrand;
declare var absoluteSym: unique symbol;
type AbsolutePathBrand = tag { [absoluteSym]: void; };
type AbsolutePath = string & AbsolutePathBrand;

or with a hypothetical type Tag<T extends string | number | symbol> = tag {[K in T]: void};

declare var normalizedSym: unique symbol;
type NormalizedPath = string & Tag<typeof normalizedSym>;
declare var absoluteSym: unique symbol;
type AbsolutePath = string & Tag<typeof absoluteSym>;

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Sep 7, 2019

I was under the impression that this would work,

declare var normalizedSym: unique symbol;
type NormalizedPath = string & tag typeof normalizedSym;

Since,

T can be any type

Is there a reason to favor the Tag<> type approach?

declare var normalizedSym: unique symbol;
type NormalizedPath = string & tag { [normalizedSym]: void; };

@fatcerberus
Copy link

fatcerberus commented Sep 7, 2019

So after my initial knee-jerk reaction favoring unique, I actually end up preferring this greatly over #33038. Issues with unique:

  • Not referentially transparent: two separate utterances of unique are different types. This is actually why I ended up hating unique & Type (looks like a type expression but is secretly a type operator), but didn’t realize it at the time.
  • Difficult to explain to newbies why unique string is not assignable to unique string.
  • Antithetical to structural typing, which is otherwise pervasive.

tag on the other hand, is referentially transparent (string & tag { foo: void } is always the same type everywhere it occurs), is always assignable to itself, and is based on the familiar structural typing rules so no surprises there either. It checks all the boxes that unique doesn’t!

My one concern is, do we need the : void? The types of the tag properties don’t matter (they don’t type any actual data) so seems like it could just be left off to avoid some unnecessary complexity/cognitive overhead. No reason to make the end-user write types they don’t actually need to care about.

Edit: Allowing the tag properties to be typed enables phantom types to be expressed very easily; ignore the above paragraph.

@fatcerberus
Copy link

fatcerberus commented Sep 7, 2019

The types of the tag properties don’t matter

On the other hand, if the type of the tag properties is not artificially limited to void then I think this feature could be used to implement phantom types/GADTs; that is hard today because if you don’t use a generic type parameter in the type then it doesn’t affect assignability. But if you can use it in a tag that doesn’t actually type real data... that changes things!

So in case it wasn’t obvious: I retract my previous suggestion of dropping the types from the tag literal. 😉

@leemhenson
Copy link

leemhenson commented Sep 7, 2019

One of the rough edges with existing branding techniques like this is that the printed type is pretty noisy once you have multiple brands assigned at the same time. For example, using brand in io-ts (https://github.com/gcanti/io-ts/blob/master/src/index.ts#L478-L507) you end up with nested Branded:

id: t.Branded<t.Branded<string, NonEmptyStringBrand>, UuidBrand>

When you use a lot of these sorts of types, you can end up with compound types that are difficult to read. Would this PRs approach help in this regard? The other PR intuitively seems to suggest that if I create a nominal type along the lines of

type NonEmptyString = unique string;
type Uuid = unique string;
type UserId = NonEmptyString & Uuid;

then I'd just see

id: UserId

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Sep 7, 2019

They can "fix" that nesting by changing the definition of Branded to be,

export type Branded<A, B> = [A & Brand<B>][0]

This should then give you,

id: string & Brand<NonEmptyStringBrand> & Brand<UuidBrand>

So, the problem isn't a problem with branding in particular

@jack-williams
Copy link
Collaborator

jack-williams commented Sep 9, 2019

Small Q: What is the planned behaviour of the following?

type GetTag<T> = T extends tag infer U ? U : never

Right now I don't think it was working, but is the plan for it to infer the tag as one would expect?


Is there strong motivation for having tags be in the domain of types, beyond implementation? Clearly you get a huge amount of the implementation for free, but I wonder if the user experience in the most general case is negatively impacted. As an example, it would seem natural, or at least satisfy most requirements, to write :

type NormalizedPath = string & tag "NormalizedPath";
type AbsolutePath = string & tag "AbsolutePath";
type NormalizedAbsolutePath = NormalizedPath & AbsolutePath;

declare function isNormalizedPath(x: string): x is NormalizedPath;
declare function isAbsolutePath(x: string): x is AbsolutePath;
declare function consumeNormalizedAbsolutePath(x: NormalizedAbsolutePath): void;

const p = "/a/b/c";
if (isNormalizedPath(p)) {
    if (isAbsolutePath(p)) {
        consumeNormalizedAbsolutePath(p);
    }
}

however here the tag of p in the inner branch is tag never. I might ask why?, and the answer is because no value satisfies the types "NormalizedPath" and "AbsolutePath". That answer doesn't seem particularly satisfactory because you are asking users to reason about structural properties of types in a context that is largely non-structural, or at least, the structure is completely independent to the values you are trying to reason about.

The intended way to write this: string & tag { NormalizedPath: void }, feels like it is only motivated by the fact that object types compose under intersection, but I have a hard time connecting this to my intuition of branded types.

Are there use-cases where having tags be a type is either very intuitive, or capable of expressing some clearly desirable pattern? I'm not yet convinced GADT's fallout of this nicely, but I'm more than happy to be corrected. I'm sure someone will come along with an incredibly detailed implementation of units-of-measure, but these complicated conditional types are really hard to debug and understand.

So I guess my point is here: if we are assuming a blank slate to implement a new feature with new syntax, is this approach the best approach for most cases most of the time? Or as an open challenge:

  • What are examples where you want to be parametric in the tag, other than defining Tag?
  • What are examples where having the tag be a type provides a significant boost in Simplicity, Usability and Soundness (to quote the design goals). I have no doubt there are very complex examples, but they should provide benefit to mere mortals!

I'm not really sure what is better right now, I'm just curious to understand the design space more and see how people would use it.

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Sep 9, 2019

I hadn't considered the tag never thing. Now it makes sense why he had,

type Tag<T> = /*snip object*/

detailed implementation of units-of-measure

F#'s implementation is so good


What are examples where you want to be parametric in the tag, other than defining Tag?

Starts to write example

complicated conditional types are really hard to debug and understand.

What are examples where having the tag be a type provides a significant boost in Simplicity, Usability ...

... they should provide benefit to mere mortals!

Ah. Nevermind.


One such complicated example that would benefit from structural tags,

#15480 (comment)

@weswigham
Copy link
Member Author

weswigham commented Sep 9, 2019

What is the planned behaviour of the following?

Yeah, that should work (at least I don't see why it would not). Thanks for the report - I think because I have StructuralTag types marked as Instantiable, the conditional is never resolving. I'll need to fix that.

Are there use-cases where having tags be a type is either very intuitive, or capable of expressing some clearly desirable pattern?

Phantom types, mostly. Much like with our implementation of mapped types and conditional types, I think having a general underlying mechanism that enables multiple usecases, but also presents with a simple interface for common cases (like Partial), is a good compromise. That's why I also mention adding a type Tag<T extends string> = tag {[K in T]: void}; to the standard library, this way you can just write string & Tag<"AbsolutePath">. Also, this syntax lends itself well to migration of tag-like objects that exist today. If anywhere in your code, you, today, have a string & {__myBrand: void}, you can trivially upgrade your experience (no more ghost completion members from brands!) by just dropping a tag keyword in front of the branding object type.

incredibly detailed implementation of units-of-measure

Most certainly not possible. A key feature of units of measure is composition over algebraic operators (imo), which this does not enable. So while you could track units, you can't manipulate them easily, so... eh.

What are examples where you want to be parametric in the tag

Some of the functional utility libraries I've read (rxjs, I think, is one) occasionally have "fake" structural members, whose existence is solely to traffic type information around to be used in assignability checking (the type stored in the brand establishes relationships between instances) and extracted later. A phantom type, if you will. Honestly, this feature, as written, while written to satisfy branding, is moreso an implementation of storage for phantom types (since we're structural, even a "phantom" type parameter needs to appear somewhere to indicate how it should be compared), which as it happens can be used for branding.

@jack-williams
Copy link
Collaborator

jack-williams commented Sep 9, 2019

They are fair points, but just to debate the pointfor the sake of it:

I think solving multiple use cases is only one aspect: it's probably worth evaluating it against the distribution of uses cases and how well each one is addressed.

What is the magnitude of the improvement for the phantom type use case? Not having ghost members is useful, as is an optimised representation, but if you're doing anything non-trivial you'll still be doing complex type logic or relying on casts to push through generic constraints that are hard to verify. Not to mention that phantom types are never really going to be that useful unless you have the associated constraints discharged on 'pattern matching', IMO.

Conversely, it seems like the majority of users really do want branding (at least from the examples in associated issues) and there is the opportunity to deliver first-class support for that feature.

Though, if I'm being honest, if there was new logic added for type display such that NormalizedPath & AbsolutePath printed as:

string & Tag<"NormalizedPath" | "AbsolutePath">

rather than:

string & tag ({ NormalizedPath: void } & { AbsolutePath: void })

it would probably be fine.

And FWIW, I prefer this to unique.


This was the units-of-measure implementation I was thinking of: https://github.com/jscheiny/safe-units.

@weswigham
Copy link
Member Author

weswigham commented Sep 9, 2019

Though, if I'm being honest, if there was new logic added for type display such that NormalizedPath & AbsolutePath printed as:

@jack-williams I've done one better - not only are instances of the global Tag (which I've now added to the lib) preserved, tags which are references to any accessible alias are preserved. So you can use whatever funky Tag-like alias your heart desires, so long as individual intersection members within the tag type when individually looked at inside of a tag have an associated alias.

Also tags now distribute over unions, because I neglected that in the first pass, and it really doesn't seem to make as much sense if they don't. This means tag (A | B) === (tag A) | (tag B), and we use the second representation internally. Technically, they already distributed over intersections, so tag (A & B) === (tag A) & (tag B), however I've chosen the first to be the canonical representation in the compiler (tag (A & B)) simply because while that makes construction a bit more interesting (tags within intersections are combined upon construction), it makes doing comparisons later much simpler (we just match the source tag component to the target tag component and unwrap).

@weswigham
Copy link
Member Author

weswigham commented Sep 9, 2019

@typescript-bot pack this again now that we do inference, unions, and aliases better

@typescript-bot
Copy link
Collaborator

typescript-bot commented Sep 9, 2019

Heya @weswigham, I've started to run the tarball bundle task on this PR at b38d95f. You can monitor the build here. It should now contribute to this PR's status checks.

@typescript-bot
Copy link
Collaborator

typescript-bot commented Sep 9, 2019

Hey @weswigham, I've packed this into an installable tgz. You can install it for testing by referencing it in your package.json like so:

{
    "devDependencies": {
        "typescript": "https://typescript.visualstudio.com/cf7ac146-d525-443c-b23c-0d58337efebc/_apis/build/builds/43296/artifacts?artifactName=tgz&fileId=585B96031A196D83F5C0068E4517F7C86D63D46FF29D9EF161EB97D54CAA456F02&fileName=/typescript-3.7.0-insiders.20190909.tgz"
    }
}

and then running npm install.

@joshburgess
Copy link

joshburgess commented Sep 10, 2019

This looks interesting and syntactically reminds me of how the library newtype-ts works. A type with a phantom type as implemented in this PR,

type Key<A> = string & tag { Key: A }

, is currently implemented like the following in newtype-ts:

interface Key<A> extends Newtype<
    { readonly Key: unique symbol, readonly phantom: A },
    string
  > {}

although the newtype-ts version resolves to a type containing false/ghost properties, obviously, because Newtype is this:

interface Newtype<URI, A> {
  readonly _URI: URI
  readonly _A: A
}

While this gives you the power to express phantom types by altering the nominal type via a structural means, I do think it sort of leaks implementation details that the user shouldn't necessarily need to care about.

It's definitely better than not being able to do this at all, but I think it would be more ideal if such structural modification was done behind the scenes without the user needing to explicitly modify the structure of a tag.

@fatcerberus
Copy link

fatcerberus commented Sep 10, 2019

Phantom types, mostly.

🎉 🎊

YES! I was starting to think I was the only one who saw the value of these, but they're a bit tricky to represent under the current realization of structural typing...

@jack-williams
Copy link
Collaborator

jack-williams commented Sep 10, 2019

@weswigham Those baselines look really nice now.

Would it be possible for an interface to extend a tag type?

interface Parent extends Tag<"A"> { }

interface Child1 extends Parent, Tag<"B"> { }

interface Child2 extends Parent, Tag<"C"> { }

@chriskrycho
Copy link

chriskrycho commented Sep 11, 2019

I tried pulling that tgz and using it locally and it failed… did I do something wrong?

@weswigham
Copy link
Member Author

weswigham commented Sep 11, 2019

Most slashes have some kind of meaning in a shell, so npm i <url> is prone to not working - that's why the instructions give you a package.json block.

@weswigham
Copy link
Member Author

weswigham commented Sep 18, 2019

@typescript-bot pack this

@typescript-bot
Copy link
Collaborator

typescript-bot commented Sep 18, 2019

Heya @weswigham, I've started to run the tarball bundle task on this PR at cc8764f. You can monitor the build here. It should now contribute to this PR's status checks.

@typescript-bot
Copy link
Collaborator

typescript-bot commented Sep 18, 2019

Hey @weswigham, I've packed this into an installable tgz. You can install it for testing by referencing it in your package.json like so:

{
    "devDependencies": {
        "typescript": "https://typescript.visualstudio.com/cf7ac146-d525-443c-b23c-0d58337efebc/_apis/build/builds/44413/artifacts?artifactName=tgz&fileId=3C63909A10CD85438ED2E46A63FF4EDC8B55970B36B8C101C2AB2BE0F7206BB602&fileName=/typescript-3.7.0-insiders.20190918.tgz"
    }
}

and then running npm install.

@weswigham
Copy link
Member Author

weswigham commented Sep 18, 2019

@typescript-bot pack this one last time just to check if lints and scripts do work now

@typescript-bot
Copy link
Collaborator

typescript-bot commented Sep 18, 2019

Heya @weswigham, I've started to run the tarball bundle task on this PR at cc8764f. You can monitor the build here. It should now contribute to this PR's status checks.

@typescript-bot
Copy link
Collaborator

typescript-bot commented Sep 18, 2019

Hey @weswigham, I've packed this into an installable tgz. You can install it for testing by referencing it in your package.json like so:

{
    "devDependencies": {
        "typescript": "https://typescript.visualstudio.com/cf7ac146-d525-443c-b23c-0d58337efebc/_apis/build/builds/44414/artifacts?artifactName=tgz&fileId=3C63909A10CD85438ED2E46A63FF4EDC8B55970B36B8C101C2AB2BE0F7206BB602&fileName=/typescript-3.7.0-insiders.20190918.tgz"
    }
}

and then running npm install.

@weswigham
Copy link
Member Author

weswigham commented Sep 18, 2019

Alright, sorry for the spam for anyone watching - I had to debug a small issue with the build, but the tarball should be functional again.

@chriskrycho
Copy link

chriskrycho commented Sep 18, 2019

Thanks for getting it straightened out!

@typescript-bot
Copy link
Collaborator

typescript-bot commented Sep 18, 2019

It looks like you've sent a pull request to update our 'lib' files. These files aren't meant to be edited by hand, as they consist of last-known good states of the compiler and are generated from 'src'. Unless this is necessary, consider closing the pull request and sending a separate PR to update 'src'.

@ProdigySim
Copy link

ProdigySim commented Sep 18, 2019

Would it be possible to support Record/index types with either of these proposals?

A common pattern in redux involves creating a "normalized" store of objects, where objects are stored in maps indexed on unique identifiers. Since redux stores are POJOs, we can't simply use Map<K,V> for these, preferring instead Record<K, V>. I'm using DIY tag types today, and we have to fall back to Record<string, T> for these.

Here's some simple test code that currently errors on the experimental build:

type UserId = string & Tag<'unique-userid'>;
const userRec: Record<UserId, string> = {};
const userMap: {
  [idx: UserId]: string;
} = {};

declare const userId: UserId;
userRec[userId] = 'bob';
console.log(userRec[userId]);
userMap[userId] = 'bob';
console.log(userMap[userId]);

It seems like if the tag types are truly phantom we might be able to use these as index types.

@typescript-bot
Copy link
Collaborator

typescript-bot commented Sep 18, 2019

It looks like you've sent a pull request to update our 'lib' files. These files aren't meant to be edited by hand, as they consist of last-known good states of the compiler and are generated from 'src'. Unless this is necessary, consider closing the pull request and sending a separate PR to update 'src'.

1 similar comment
@typescript-bot
Copy link
Collaborator

typescript-bot commented Sep 18, 2019

It looks like you've sent a pull request to update our 'lib' files. These files aren't meant to be edited by hand, as they consist of last-known good states of the compiler and are generated from 'src'. Unless this is necessary, consider closing the pull request and sending a separate PR to update 'src'.

@weswigham
Copy link
Member Author

weswigham commented Sep 18, 2019

Would it be possible to support Record/index types with either of these proposals?

You'd just need #26797 (with either)

@ProdigySim
Copy link

ProdigySim commented Oct 9, 2019

Another minor difference I thought of today while debugging an issue with a manual Opaque<T,K> solution: These "official" tag types do not extend object.

You can try this code against the test build the bot created:

// Experimental Tag Types from this PR
type Builtin = string & Tag<'builtin'>;

// true
type yy = Builtin extends string ? true : false;
// false
type zz = Builtin extends object ? true : false;

// DIY tag type solution
type RetroTag<T, K> = T & { __tag: K }
type Retro = string & RetroTag<string, 'retro'>;

// true
type aa = Retro extends string ? true : false;
// true
type bb = Retro extends object ? true : false;

In my case, today I had to special case a type conditional to avoid having my tag types be treated as objects. With the new behavior exhibited by the builtin implementation here, I wouldn't have had to.

So, I'd consider this an improvement over the DIY solution. But probably worth noting for anyone trying to convert from DIY to builtin.

@sandersn
Copy link
Member

sandersn commented May 24, 2022

This experiment is pretty old, so I'm going to close it to reduce the number of open PRs.

@shicks
Copy link
Contributor

shicks commented May 26, 2022

Would the authors consider revisiting this work? There seem to be quite a few people interested in the feature (see the recent comments on #33038), though this approach seems to be the better approach.

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented May 27, 2022

This is effectively an implementation sketch for #202. We'd prefer people interact with the suggestion issues than the PRs, since the implementation of a feature is mechanical once the design has been worked out, and nominal types are still very much under discussion

@microsoft microsoft locked as resolved and limited conversation to collaborators May 27, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Experiment
Projects
Design Meeting Docket
  
Awaiting triage
Development

Successfully merging this pull request may close these issues.

None yet