Introduce the Address type
#3089
Replies: 5 comments 2 replies
-
|
I will need to read-through this solution more, but my original plan was just to wait until nominal types are added to TypeScript. I may move this to the “Ideas Discussion” area than an issue? I’ll read it over a few more times first though. |
Beta Was this translation helpful? Give feedback.
-
|
Great to hear. Yes, from my current reading on their progress with nominal types it's a way's off as it is arguably their most disruptive change. I haven't finished the full thesis as I fat-fingered post but am editing the final bits |
Beta Was this translation helpful? Give feedback.
-
|
@ricmoo Feel free to move to an Idea Discussion. There are certainly multiple implications to introducing this that would have to be thrashed out. |
Beta Was this translation helpful? Give feedback.
-
|
@ricmoo I'm thinking of submitting a mvp PR of what I think this approach might look like in the v6 beta. I have looked at some of the v6 beta branch (which may I see includes bigint for evm num types! +1), specifically the |
Beta Was this translation helpful? Give feedback.
-
|
Instead of abusing underscores, using symbols here might be a better solution. declare const Nominal: unique symbol;
export type Nominal<T extends string> = { [Nominal]: { [k in T]: void } };
export type Hex = string & Nominal<'Hex'>;
export type Address = Hex & Nominal<'Address'>;
export type ContractAddress = Address & Nominal<'ContractAddress'>; |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Structural vs Nominal typing
One of the most discussed features in typescript is that around "non-structural (nominal)" typing. This thread goes back nearly 8 years on the subject.
For the unawares reader, typescript's type system considers a type
Ato be equivalent to typeBifBhas at least the same members ofA.The informal label for this is
duck-typingwhere there is a phrase "if it walks like a duck and quacks like a duck than it's probably a duck". This makes typescript's type system very flexible as developers can easily spin up their own interfaces where necessary and don't have to extend their dependency tree to find the exact type for an api.A nominal type system has the property where all types are unique and distinct of each other, regardless of the nature of equivalence of their properties. Typescript does not support nominal types but as in the linked thread, there are discussions surrounding how they can be added. However, even in the current version of typescript, it is possible to implement a "simulation" of them while incurring some verbosity and minor inconveniences that I will touch on.
The above example exemplifies a way where two objects which are structurally similar can be kept distinct through the inclusion of a "brand" property
__brand. This is a very important tool in a typescript developer's toolbox because often when working in a project with multiple other developers, there may be an implicit rule that we should never assign anAliento aPersoneven though the rules of the type system enable us to do so. By using the brand example above, we can make that rule explicit and have the compiler tell us when that mistake has been made, thus improving code quality and preventing bugs at the source.That being said, the above is a somewhat contrived example and the intent could be expressed better using a higher generic type, e.g
LifeForm<T extends "Alien" | "Person">or using akindenum where the implied hidden__brandwould be replaced with an explicitkindproperty however that is a digression.How to use Nominal types for domain modelling
Where this "nominal" typing approach is best used is in implementing constraints on primitive types, primarily
stringandnumber. This becomes very useful as we can explicitly and safely model an arbitrary domain at the cost of some runtime overhead. Imagine we wanted to mirror theuint8datatype from the evm in a ts/js environment safely.First lets introduce the
Nominalgeneric type which will be used use now and later.On to the example:
This approach technically isn't nominal typing as we can invoke another type
Uint8Altthe same way and both will be equivalent. What is important is that it incurs a compile time constraint on thenumbertype enabling us to do this:No we nicely have
doubleUint8(y)throwing a compile time error and also provesUint8's backwards compatibility with the originalnumbertype when we calldoubleNumber(x). However, thedoubleUint8operates exactly asdoubleNumberdoes and merely dangerously recasts as aUint8. This must be replaced with a type guard function:We would rewrite
doubleUint8as:Having the type guard
isUint8gives us the guarantee of the correctness of our code at the cost of runtime overhead. This pattern is called "runtime type checking" as the developer must add functionality to validate domain constraints. Unfortunately, doing something like this in a real project is impractical as any operation over a "nominal number" likeUint8will always revert to a number, e.gUint8 + number = number. An extensive library would have to be written to redefine basic numeric operations and others to make it practical.The
AddresstypeOne place where I think the "nominal" approach would be useful is in explicitly representing Ethereum addresses distinct from the
stringtype. Much like auuid, an "address" is unique and in it's usage is not "operated" on as often as numbers leaving only conversions to upper/lower casing. Once a string is validated as anaddressit is not subject to change and so once defined, there is not going to be huge overhead in using it.Mirroring the
Uint8definition, the Address type would look like:For the type guard, ideally the
ethers.utils.isAddress()function would be that but it is trivial to wrap around that:Again much like
Uint8, theAddresstype when used in a function interface will throw a compiler error but preserves backwards compatibility withstring.Taking this example further we can extend on
Addresswith another nominal "tag" to provide constraint on a particular address or a set of addresses.The goal of using this approach is that a developer building an SDK or frontend for a smart-contract system would be able to model the collection of valid addresses and discriminate the associated business logic for the correct "address" type in each case. To be clear, the above example would be what is built on the
Addresstype that Ethers would provide.There are some drawbacks that we would have to be aware about if we were to include this right now. One of the typical ways a developer may use these
Addresstypes is using them as keys in objects. We can create a typeRecord<Address, { ... }>but the operating on such an object usingObject.keysorObject.entrieswill reduce the keyAddresstype to a string. I believe the same is the case for lodash and other popular tooling. It may be the case that ethers would have to provide akeysorentriesmethods to ergonomically preserve the Address.Beta Was this translation helpful? Give feedback.
All reactions