RJ
RJ2y ago

Branded ID type constructor function

I like that IDs have been changed from classes to branded strings in 0.17, and I also like that they're treated as opaque (not constructable directly). But while upgrading, I just came across a situation in which I think it would make sense to be able to obtain an ID<"myTable"> from a string—when serializing the ID to the DOM, e.g. as the value attribute of a <select> element. The example in my code, snipped verbatim:
<Select.Root
value={filterByBrandPartnerId}
onValueChange={(id) =>
dispatch({
_tag: "FilterByBrandPartnerChanged",
filterByBrandPartnerId: id, // <-- id comes from the DOM as a string
})
}
>
<Select.Root
value={filterByBrandPartnerId}
onValueChange={(id) =>
dispatch({
_tag: "FilterByBrandPartnerChanged",
filterByBrandPartnerId: id, // <-- id comes from the DOM as a string
})
}
>
Perhaps it would be nice, for scenarios like these, to offer a function which tries to create a branded string by validating that the string is the correct format (length/content) for a Convex ID? I guess that would just mean validating that it's a UUID (v4). Something like:
import type { Id } from "~/convex/_generated/dataModel";

...

<Select.Root
value={filterByBrandPartnerId}
onValueChange={(id) =>
dispatch({
_tag: "FilterByBrandPartnerChanged",
filterByBrandPartnerId: Id.fromString<"brandPartners">(id)
})
}
>
import type { Id } from "~/convex/_generated/dataModel";

...

<Select.Root
value={filterByBrandPartnerId}
onValueChange={(id) =>
dispatch({
_tag: "FilterByBrandPartnerChanged",
filterByBrandPartnerId: Id.fromString<"brandPartners">(id)
})
}
>
Of course I can coerce the string to the type I'd like, but this alternative could be nicer. Just a data point and a thought 🙂
7 Replies
sshader
sshader2y ago
Yeah totally -- we're currently not exposing enough information to clients to be able to tell whether an ID is in a particular table (leaning on db.normalizeId and argument validators like v.id("brandPartners") instead). But would love for us to support something like this from client code someday -- thanks for the concrete example!
ian
ian2y ago
Out of curiosity - are there scenarios where you'd want an ID validator with weaker guarantees - e.g. that just validate that it looks like an ID, but doesn't validate it's for a specific table? On the backend the distinction is pretty serious b/c things like db.get will access whatever table the ID is for, regardless of the type at compile-time.
RJ
RJOP2y ago
Yeah @ian, that's what I had in mind with my suggestion above—validation just that it looks like an ID. While obviously not as good, the current state of things is that I need to assert that an arbitrary string is an ID, and a validation check that at least it looks like it's an ID would still protect against some bugs! In fact, I'd guess that asserting that a non-ID string is an ID is the more likely failure mode for most people's code than asserting that an ID for one table is rather an ID for another (but even if not, the value is still there).
ian
ian2y ago
I wonder what the right verbage would be useful to avoid folks over-estimating the security of it. It would be unfortunate if they exposed a vulnerability b/c they used the wrong validator server-side. Maybe it could just validate that it's an ID but make it clear the table is a type? I like your suggestion, since Id.fromString<"myTable">(id) has the table not as a parameter. It's odd to say fromString when it is still a string subtype. Would something like checkId<"brandPartners">(id) make it clear?
RJ
RJOP2y ago
I think technically string isn't a subtype of something like string & { _brand: "myTable" } (rather the opposite) but I take your point that it could still be confusing, especially because of the peculiarities of how branded types function in general. You could always lean towards a dangerous-sounding name to ensure confusion in a safer direction, if there is any 😄. Something like coerceId<"myTable">(id)? Not sure Defaulting the type parameter to any (or unknown?) could perhaps also make it more clear that if you pass something in there, it's just an assertion E.g.
coerceId(id) // Id<any> or Id<unknown>
coerceId<"myTable">(id) // Id<"myTable">
coerceId(id) // Id<any> or Id<unknown>
coerceId<"myTable">(id) // Id<"myTable">
Another scenario I'm encountering now—storing a Convex ID in localStorage
ian
ian2y ago
What I've done is cast it to the Id type when reading it, and validating it when passing it to a cloud function, since that's where it has the risk of reading from the wrong table Is the ask to have a way to validate something coming from localStorage as being of an ID format and determining the table? Or is there a usecase I'm missing?
RJ
RJOP2y ago
I'm doing all of those things as well! In this case, I was using localStorage to store search filters which I want to persist between page visits (the ID is a filter parameter). When the page loads, I want these "last-used" filters to be used as parameters to the query, unless they are absent or malformed, in which case I just want to allow the query to use its own default filtering parameters. For other types of filter values which I have in localStorage, it's possible to use a library like zod to parse out the value and ensure it is a valid parameter (or not) and then to bail or proceed accordingly. But my concern with casting was that I'm more likely to pass along an invalid ID to the query and then have the query fail, or else have to handle this localStorage concern at that layer, as well. Which isn't as ideal, because I really want this localStorage caching feature to give up instantly as soon as there's any hint that some data might not be correct, so that I don't have to version it as I make changes to the app. Does that make sense? I still think it's probably not a huge deal, especially because you can only really ever be so sure that some arbitrary string you receive is a valid ID for some table without involving the database. But that was the thinking that led to the mention!

Did you find this page helpful?