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.
-
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
A
to be equivalent to typeB
ifB
has at least the same members ofA
.The informal label for this is
duck-typing
where 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 anAlien
to aPerson
even 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 akind
enum where the implied hidden__brand
would be replaced with an explicitkind
property 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
string
andnumber
. 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 theuint8
datatype from the evm in a ts/js environment safely.First lets introduce the
Nominal
generic 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
Uint8Alt
the same way and both will be equivalent. What is important is that it incurs a compile time constraint on thenumber
type enabling us to do this:No we nicely have
doubleUint8(y)
throwing a compile time error and also provesUint8
's backwards compatibility with the originalnumber
type when we calldoubleNumber(x)
. However, thedoubleUint8
operates exactly asdoubleNumber
does and merely dangerously recasts as aUint8
. This must be replaced with a type guard function:We would rewrite
doubleUint8
as:Having the type guard
isUint8
gives 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" likeUint8
will 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
Address
typeOne place where I think the "nominal" approach would be useful is in explicitly representing Ethereum addresses distinct from the
string
type. 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 anaddress
it is not subject to change and so once defined, there is not going to be huge overhead in using it.Mirroring the
Uint8
definition, 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
, theAddress
type when used in a function interface will throw a compiler error but preserves backwards compatibility withstring
.Taking this example further we can extend on
Address
with 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
Address
type 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
Address
types is using them as keys in objects. We can create a typeRecord<Address, { ... }>
but the operating on such an object usingObject.keys
orObject.entries
will reduce the keyAddress
type 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 akeys
orentries
methods to ergonomically preserve the Address.Beta Was this translation helpful? Give feedback.
All reactions