Skip to content

types: Use a distinct UserId type for user IDs.#4421

Merged
gnprice merged 24 commits into
zulip:masterfrom
gnprice:pr-userid-type
Jan 20, 2021
Merged

types: Use a distinct UserId type for user IDs.#4421
gnprice merged 24 commits into
zulip:masterfrom
gnprice:pr-userid-type

Conversation

@gnprice
Copy link
Copy Markdown
Member

@gnprice gnprice commented Jan 20, 2021

This makes a new UserId type as a Flow opaque type alias, like so:

export opaque type UserId: number = number;

The effect of this is that:

  • at runtime, a UserId is simply a number, with no added overhead;
  • a UserId can be freely converted to a plain number (because of the : number bound);
  • but to create a UserId from a plain number can only be done by code in the same module as the type definition. The effect of this part is that it requires invoking an explicit function makeUserId which we export from that module.

This requires touching a lot of code and, frankly, I'm not sure it was worth the effort required to prepare the changes. But with that work done, I think it definitely makes the code nicer than it was without it. It moves one more set of facts about our code out of the realm of unchecked commenting and naming, and into the realm of machine-checked types.

That is, construct it more systematically, and get data on
test users from the exampleData code.
Many of these can't possibly work in the first place -- how can we
edit a user group, for example, without providing any data on what
we want to change about it?

In any case, we don't use them, and have no plans to in the short or
medium term.  When we do, it's not hard to add bindings, and really
that will only be made easier by not having broken ones lying around.
The imports belong to the whole file, and this had the import placed
inside the first section of it.
We'll use this in waves over the coming series of commits.

Because it's public that `UserId` is a subtype of `number`, other code
can freely take a `UserId` and use it as if it were simply a `number`.

That means we can do the conversion gradually so long as we start with
places where a user ID is made, and follow the data flow to places
where it's passed to and ultimately consumed: while we're in the
middle of the conversion, the boundary will always look like some code
providing a `UserId` and some other code accepting it as a `number`.
In places where these test helpers accept a user ID, we'll keep
accepting plain numbers, to just reduce the number of places where
test code has to say `makeUserId`.  But where they provide a user ID,
start specifying that it's not just any number but a genuine `UserId`.
Where we make up a user ID in test code, mark it in the type system
as being a user ID and not just any old number.

We'll soon adjust the types of our many functions that expect data
containing a user ID, so that they require this marking.  This change
prepares for that.
@gnprice gnprice requested a review from chrisbobbe January 20, 2021 21:43
Copy link
Copy Markdown
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, thanks!

See also a small comment and a query, below; otherwise, this LGTM.

Comment thread src/webview/js/js.js
WebViewInboundEventReady,
WebViewInboundEventMessagesRead,
} from '../generateInboundEvents';
import { makeUserId } from '../../api/idTypes';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should run tools/generate-webview-js at this commit.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, thanks for the catch!

Comment thread src/api/idTypes.js
*/
export const makeUserId = (id: number): UserId => id;

/* Possible future work:
Copy link
Copy Markdown
Collaborator

@chrisbobbe chrisbobbe Jan 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible future work:

Makes sense. Do you think it'll always be pretty clear whether a given ID type belongs here in src/api/idTypes.js, vs. somewhere else?

I'm thinking of the return value of keyFromNarrow, which we've discussed making an opaque type for. I think that pretty clearly belongs right alongside keyFromNarrow, in narrows.js. I think I'd say it doesn't belong in src/api because we never pass those keys to the API, and we never receive them from the API. We do pass the result of apiNarrowOfNarrow to the API.

I think this is a sensible criterion for putting ID types in src/api/idTypes.js: if we ever talk to the API using that type of ID, its opaque type should live here. Perhaps that goes without saying, but we do a lot of passing-around of IDs within the app (including of user IDs, stream IDs, and our own narrow keys), so I wonder if a small comment might be helpful. 🙂

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The basic principle is that src/api/ is self-contained, so that code there shouldn't import code elsewhere -- it's describing the server API, and behaves like an independent library for doing that. We've made a few exceptions to that principle for convenience, but are generally pretty consistent about it.

So that means that any type that's going to be used in the API should be defined in the API part of the code. If we introduce a StreamId or MessageId, we'll want to use it in the API, so it'll be defined there.

The narrow-keys returned by keyFromNarrow don't appear in the API; they're our own invention for within the app. So they naturally wouldn't be defined in src/api/, and instead would appear in narrows.js as you say.

Because this is a more general principle I don't think it calls for a comment on this specific instance. But it'd be good to write down that general principle, which I'm not sure we have. Any suggestions on where would be most helpful?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, thanks! I've just merged ba7108f, wiki-style.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, thanks!

This NULL_USER object should really go away, and perhaps it will soon.
Until then, mark this fake user-ID value of -1 as indeed being meant
as a (fake) user ID.
Specifically, use it in all the places that are purely an
"output position" (or nearly so), i.e. where we consume a value
from this type but don't try to put a value back into it.

This leaves out a few parts of the API we'll get to in
subsequent commits:

 * a few places which are "input positions", meaning the API code
   accepts values of this type instead of providing them -- in
   particular when we're providing user IDs as parameters;

 * a few places where we reuse the type for new values we construct
   for our own internal data structures, so that the type is in an
   input position there as well as an output position.

Those will require updating more of the app too, so that we can
supply UserId values.
This PmRecipientUser type is one we reuse in some data structures
we construct ourselves, in particular outbox messages.  So to
tighten its type, we need to propagate the fact that these values
are user IDs through the various code that handles them on their
way there, in particular the Narrow type and its constructors.
This propagates the UserId type through where we store in Redux the
self user's user ID, to the `getOwnUserId` selector that gets that
information, and on to all the values fed by that selector.

We already have a pretty consistent convention of calling those values
`ownUserId`, so this is an easy change to make.
This change in the API types is tied together with a small change in
our code that consumes it, for a bit of a silly reason: we want to
sort this array, and we'd rather do so in place rather than making an
unnecessary copy, and that means it can't be `$ReadOnlyArray`.

In principle this could be avoided by Flow having something
intermediate between plain `Array` and `$ReadOnlyArray`, with a name
like `$CovariantArray`.  Like `$ReadOnlyArray`, this would exclude
methods like `push` that accept a new item, and that therefore aren't
safe to use on an array that some other code might expect to have a
more specific type of element.  But unlike `$ReadOnlyArray`, it would
include `sort`, and `pop`, and other methods that mutate the array but
only provide elements as output, never taking them as input, and
therefore are just fine for that situation.

But Flow doesn't currently have that feature, so just treat this type
covariantly and update both the provider and consumers in lockstep.
We do this only partially, due to a Flow bug in the interaction of
indexer types (i.e. the types of objects-as-maps) and opaque types.
We reuse this PmsUnreadItem type for the data structures we maintain
over time for this data, so tightening its type requires us to adjust
a type in the code that maintains it.
This makes the meaning of the `unsubscribedMentions` state-property
rather clearer!  The elements are user IDs.
All these changes are pretty boring.

Many of them are independent of each other, but others aren't because
one component passes a user ID to another, either as a child or by
navigation.

This change is limited to the places where a user ID, or list of them,
appears in order to specify particular users that this component, this
time, should do its work with respect to.  Upcoming commits will take
care of places where we pass general information that happens to
contain user IDs.
The data we're passing to this code comes from PM recipients,
where we've already marked the user IDs as type `UserId`.  So
when we tighten the type here, Flow already accepts that.

This in turn causes Flow to see that this code's local helper
`getRecipients` operates on `UserId`s.  It passes the user IDs
to one of our Redux selectors; so in an upcoming commit, when we
tighten those selectors to expect `UserId`, Flow will see that
this change is in fact required.
This comes after converting React props and route props, which
provide the arguments to these selectors at most call sites,
and before converting the `get*UsersById` selectors which provide
the data structures these look up data in.
In this commit, we update the selectors that return maps of users
by ID to mark the keys as being specifically user IDs, not just any
numbers; and we propagate that change through all the values that
just pass on these selectors' return values.
…ion.

This takes us full circle from the early part of this series,
where we started using UserId where the API provides a user ID.

After this change, a search like `rg -i user.*number`, or similar
searches with "recipient" or "sender" instead of "user", turns up
only a handful of places where we have something intended to be
a user ID that's still of type `number`; and each of them has a
comment explaining why.
@gnprice gnprice merged commit 1680f7b into zulip:master Jan 20, 2021
@gnprice gnprice deleted the pr-userid-type branch January 20, 2021 23:16
chrisbobbe added a commit that referenced this pull request Jan 21, 2021
As Greg mentioned at
  #4421 (comment).

Possibly, there's a better place for this -- but I'd like to put it
somewhere before I move on to other things. So, wiki-style, I've
taken the liberty of merging this small change, which purely fills a
gap in our comments/documentation.
Gautam-Arora24 pushed a commit to Gautam-Arora24/zulip-mobile that referenced this pull request Feb 2, 2021
As Greg mentioned at
  zulip#4421 (comment).

Possibly, there's a better place for this -- but I'd like to put it
somewhere before I move on to other things. So, wiki-style, I've
taken the liberty of merging this small change, which purely fills a
gap in our comments/documentation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants