-
Notifications
You must be signed in to change notification settings - Fork 12.5k
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
Nominal unique type
brands
#33038
Nominal unique type
brands
#33038
Conversation
I don't understand this part. If these are meant to replace branded types, then: type UString = unique string;
type BString = string & { __brand: true };
declare let ustr: UString;
declare let bstr: BString;
let str1: string = bstr; // ok because branded string is a subtype of string (this is generally desirable).
let str2: string = ustr; // could be ok because unique string is also a string.
let num: number = ustr; // never ok, should be error because unique string is NOT a number! But if we just had |
It's useful because you can then intersect it with something. Which is all |
Yeah, I need to read more closely. I just noticed the It might be represented as an intersection under the hood, but that strikes me as unnecessarily exposing implementation details. |
|
Is this proposal allows to assign literals to variable/param which has branded type? type UserId = unique string
function foo(param: UserId) {}
foo("foo") // ok
var x: UserId = "foo"; // ok
let s = "str";
var y: UserId = s; // not ok
foo(s) // not ok |
How would you make nominal classes with this? |
Not easily. You'd need to mix a distinct nominal brand into every class declaration, like via a property you don't use of type I'd avoid it, if possible, tbh. Nominal classes sound like a pain :P |
Branded types can only be "made" either via cast or typeguard, as I said in the OP, so no. This is because the typesystem doesn't know what invariants a given brand is meant to maintain, and can't implicitly know if some literal satisfies them. |
Would something like the following ever be meaningful? Probably not right.. interface Parent extends unique unknown { }
interface ChildA extends (Parent & unique unknown) { }
interface ChildB extends (Parent & unique unknown) { } |
This is probably obvious to everyone but For example, //Can be replaced with `unique number`
type LessThan256 = number & { __rangeLt : 256 }
//Cannot be replaced with `unique number`
type LessThan<N extends number> = number & { __rangeLt : N } More complicated example here, There are a few reasons why I'm generally against the idea of Cross-library interopLibrary A may have Both types will be considered different, even though they have the same name and declaration. If libraries start using this So, one starts thinking that a no-op casting function would be safer, function libARadianToLibBRadian (rad : libA.Radian) : libB.Radian {
return rad as libB.Radian;
} This is safer because you won't accidentally convert But if you have Cross-version interopIt's happened to me a bunch where I've had the same package, but at different versions, within a single project. So, v1.0.0's Now you need a casting function... Even though it's the same package. With brands, if two libraries use the same brands, even if they're different types, they'll still be assignable to each other. (As long as they don't use Library A may have Even though they're different types, they're assignable to each other. No casting needed. As an aside, I vaguely remember something from many, many years ago. I can't find it through Google anymore, though. There was discussion about adding syntax to C++ to make typedef double Radian; And this was rejected outright because of the issues I listed above. Two libraries with their own |
Re: nominal classes - classes are already nominal if they contain any private members. Just throwing that out there. 😃 |
So we can finally have things like this? @weswigham // Low-end refinement type :)
type NonEmptyArray<A> = unique ReadonlyArray<A>
function isNonEmpty(a: ReadonlyArray<A>): a is NonEmptyArray<A> {
return a.length > 0
}
// INTEGERS (sort of)
type integer = unique number
function isInteger(a: number) { return a === a | 0 } |
@fatcerberus It's also why I avoid classes entirely and avoid private members if I do have them =P |
If cross version compatibility really is an issue then an alternate solution would be to make naming explicit: Let the type type NormalizedPathOne = string & unique "NormalizedPath"; where an unlabelled type NormalizedPathTwo = string & unique // some label that we don't care about that is auto-generated. so while two declarations of FWIW I have no real preference---for me the big win of this feature is being able reduce empty intersections more aggressively.
IMO, for all brand oblivious operations a |
I've implemented Opaque types like so: type Opaque<V> = V & { readonly __opq__: unique symbol };
type AccountNumber = Opaque<number>;
type AccountBalance = Opaque<number>;
function createAccountNumber (): AccountNumber {
return 2 as AccountNumber;
}
function getMoneyForAccount (accountNumber: AccountNumber): AccountBalance {
return 4 as AccountBalance;
}
getMoneyForAccount(100); // -> error |
Your version breaks given the following, type Opaque<V> = V & { readonly __opq__: unique symbol };
type NormalizedPath = Opaque<string>;
type AbsolutePath = Opaque<string>;
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";
consumeNormalizedAbsolutePath(p); //Error
if (isNormalizedPath(p)) {
consumeNormalizedAbsolutePath(p); //Expected Error, Actual OK
if (isAbsolutePath(p)) {
consumeNormalizedAbsolutePath(p); //OK
}
} Contrast with, |
@AnyhowStep I know, but that's how I'm currently creating opaque types. I hope this Pull Request will incorporate this into the language, and make it even better than my implementation. |
Nominal types are pretty useful. The example I often use is the APIs that take latitude/longitude and bugs that are result of mixing up latitude with longitude which are both numbers. By making those unique types we can avoid that class of bugs. However, unique types can cause so much pain when you have to keep importing those types to simply use an API. So I'm hoping that at least primitive types are assignable to unique primitives where I can still call my functions like this: // lib.ts
export type Lat = unique number;
export type Lng = unique number;
export function distance(lat: Lat, lng: Lng): number;
// usage.ts
import { distance } from 'lib.ts';
distance(1234, 5678); // no need to asset types As @AnyhowStep mentioned cross-lib and cross-version conflicting unique types can also be a source of pain. Can we limit uniqueness scope somehow? Would that be a viable solution? |
#33290 is now open as well, so we can have a real conversation on what the non-nominal explicit tag would look like, and if we'd prefer it. |
BTW, this is now a dueling features type situation (though I've authored both) - we won't accept both a #33290 style brand and this PR's style brand, only one of the two (and we're leaning towards #33290 on initial discussion). We'll get to debating it within the team hopefully during our next design meeting (next friday), but y'all should express ideas, preferences, and reasoning therefore within both PRs. |
Only repo contributors have permission to request that @typescript-bot pack this |
Heya @weswigham, I've started to run the tarball bundle task on this PR at 13968b0. You can monitor the build here. It should now contribute to this PR's status checks. |
Hey @weswigham, I've packed this into an installable tgz. You can install it for testing by referencing it in your
and then running |
type integer = unique number
function isInteger(a: number): a is integer { return a === (a | 0) }
function Interger(a:number) {
if(!isInteger(a)) throw new Error("not an integer");
return a;
} |
To what extent does #31894 satisfy the same requirements as this proposal? I know @DanielRosenwasser mentioned in #38510 that while nominal brands would be nice, but that it's not clear whether it's counter to the goals of 4.0 at the moment. That said, it seems to me that one could easily enough write
where the brands are module-local and never intended to be implemented by any concrete type. Branded types already need explicit casts to instantiate them, so it's easy enough to make them. Given that that proposal seems to have more traction, does it make this one obsolete? |
Is there a status update on this PR? |
@weswigham Couple questions:
|
Oh, wow, it's been that long. Uh. Last time I presented it to the team, the response was somewhat lukewarm - there's no real excitement or drive for it right now. So I guess what we're looking to see is overwhelming demand? |
Not sure how the demand is properly measured, but this PR is in top 5 upvoted open PRs at the moment. Just saying 😁 |
@weswigham Thanks for the quick explanation! I see why both are necessary, now. Also, I definitely recommend looking into @kachkaev's comment above. |
Just used branding to very quickly identify the inconsistencies in a code base that was using string for 3 different logical dynamically valued types. Consider this my +1... |
Just a bit of data: The last codebase I was in changed from using We wanted to move to a code generation system for our web API types. We had backend code that used nominal types ("tinytypes") in Scala which was the source of our branding. Using unique-symbol based types had a couple of drawbacks here:
We ended up converting all of our nominal types to string-based tag types overnight to support this project. They solved all of these problems:
tl;dr string-based tag types seem a little more flexible with implementation details and are still good enough. They still have the hard problem of naming things, but they are fixable when there are name conflicts. We can also put out recommendations for tag patterns in shared libraries. |
This experiment is pretty old, so I'm going to close it to reduce the number of open PRs. |
Will there be any type of these I needed something like that in a project where I had to juggle like a couple of simple "number" IDs and here something like unique number type per ID would've helped to save some time debugging :D |
Is there a particular reason this experiment stalled out? |
@sandersn This is the 9th most voted on PR in the entire history of the repo, I think that's a clear indicator that a lot of people want it and it's probably worth pushing forward. Would you please reopen it? IMO this is a very valuable feature. Nominal types can greatly increase type safety in any case where a value has a specific semantic meaning and thus should not be compatible with any similarly shaped type (or primitive) as is the default. Currently that's a difficult problem to solve with typescript. I would imagine nominal types could also greatly improve compiler performance by skipping structural comparisons. IMO this should not be summarily closed simply because it hasn't had attention recently. @weswigham since you authored this, do you think it is worth continuing the work you started or should this be closed in favor of a new discussion/implementation? |
I mean, personally, I think the structural version of this PR, the |
@weswigham I'm not familiar with the PR you're referring to, would you link to it please? |
Thanks, I agree that PR seems like it is a better approach. Unfortunately it looks like that one got summarily closed as well. Given that there seems to be a lot of interest in support for some form of nominal typing perhaps it should be reopened? |
From the last comment on the other PR:
|
@weswigham Hey great man, how was this going? |
Fixes #202
Fixes #4895
We've talked about this on and off for the last three years, and it was a major reason we chose to use
unique symbol
for the individual-symbol-type, since we wanted to reuse the operator for a nominal tag later. What this PR allows:unique T
(whereT
is any type) is allowed in any position a type is allowed, and nominally tagsT
with a marker that makes onlyT
's that have come from that location be assignable to the resulting type.This is done by adding a new
NominalBrand
type flag, which is a type with no structure which is unique to each symbol it is manufactured from. This is then mixed into the argument type tounique type
via intersection, which is what produces all useful relationships. (The brand can have an alias if it is directly constructed viatype MyBrand = unique unknown
)This does so much with so little - this reduces the jankiness written into types to enable nominalness with
unique symbol
s orenum
s, while adding zero new assignability rules.So, why bring this up now? I was thinking about how "brands" work today, with something like
type MyBrand<T> = T & {[myuniquesym]: void}
whereT
could then become a literal type like"a"
. We've wanted, for awhile, to be able to more eagerly reduce an intersection of an object literal and a primitive tonever
(to make subtype reduction and intersection reduction produce less jank and recognize more types as mutually exclusive), but these "brand" patterns keep stopping us. (Heck, we use em internally.) Well, if we ever want to change object types to actually mean object, then we're going to need to provide an alternative for the brand pattern, and ideally that alternative needs to be available for awhile. So looking on the horizon to breaks we could take into 4.0 in 9 months, this simplification of branding would be up there, provided we've had the migration path available for awhile. So I'm trying to get the conversation started on this before we're too close to that deadline to plan something like that. Plus #202 is up there on our list of all-time most requested issues, and while we've always been open to it, we've never put forward a proposal of our own - well, here one is.On
unique symbol
unique symbol
's current behavior takes priority for syntactically exactlyunique symbol
, however if a nominal subclass of symbols is actually desired, one can writeunique (symbol)
to get a nominally brandedsymbol
type instead (orsymbol & unique unknown
- it's exactly the same thing). The way aunique symbol
behaves is like a symbol that is locked to a specific declaration, and has special abilities when it comes to narrowing and control flow because of that. Nominally branded types are more flexible in their usage but do not have the strong control-flow linkage with a host statement thatunique symbol
s do (in fact, they don't necessarily assume a value exists at all), so there is very much reason for them to coexist. They're similar enough, that I'm pretty comfortable sharing syntax between the two.Alternative considerations
While I've used the
unique
type operator here, like we've oft spoken of, on implementation, it's become plain to me that I don't need to specify an argument tounique
. We could just exposeunique
as a unique type factory on it's own, and dispense with the indirection. The "uniqueness" we apply internally isn't actually tied to the input type argument through anything more than an intersection type, so simply shorteningunique unknown
tounique
and reserving the argument form for justunique symbol
s may be preferable. All the same patterns would be possible, one would just need to writestring & unique
instead ofunique string
, thus dispensing with the sugar. It depends on the perceived complexity, I think. However, despite being exactly the same,string & unique
is somehow uglier and harder to look at thanunique string
, which is why I've kept it around for now. It's probably worth discussing, though.What this draft would still need to be completed:
One or more unique brands is missing from type A
with related information pointing at the brand location, rather than the current error involvingunique unknown
)unique unknown
brands)unique (symbol)
declaration emit (to ensure it's not rewritten asunique symbol
)keyof <unique brand type>
should benever
(as it is now), since the brand is top-ish (and contains no structure information itself), or if it should be preserved as an abstractkeyof <unique brand>
type, so that brand can apply keys-of-branded-types constraints in constraint positions