-
Notifications
You must be signed in to change notification settings - Fork 25
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
Add normative-conventions.md; document agreed-upon coercion rules #136
Conversation
This PR doesn't yet include object-to-primitive coercion, but I said in plenary this week that I would post a writeup to document the impact this has on library authors. I'll post it here as a reply to this thread. Note: As many of us do, I wear multiple hats. In this post, I wear my hat only as an i18n library author. Newtypes / Wrapper ObjectsA use case for coercion is the newtype idiom. The idiom is useful for attaching semantic meaning to low-level values. Coercion can be used to unwrap the newtype to its underlying primitive. class WholeNumber {
#value;
constructor(value) {
if (value < 0 || value % 1 !== 0) {
throw new Error("Not a whole number: " + value);
}
this.#value = value;
}
[Symbol.toPrimitive](hint) {
if (hint === "number" || hint === "default") {
return this.#value;
}
if (hint === "string") {
return String(this.#value);
}
}
}
let five = new WholeNumber(5);
console.log(`${five}`); // "5"
let nf = new Intl.NumberFormat("en", { maximumFractionDigits: five });
console.log(nf.resolvedOptions().maximumFractionDigits); // 5 EnumsMany libraries, including Intl, make use of string enums. class RoundingMode {
#id;
constructor() {
this.#id = "halfExpand";
}
static get ceil() {
return this.construct("ceil");
}
static get floor() {
return this.construct("floor");
}
static get halfExpand() {
return this.construct("halfExpand");
}
private static construct(id) {
let val = new RoundingMode();
val.#id = id;
return val;
}
[Symbol.toPrimitive](hint) {
if (hint === "string" || hint === "default") {
return this.#id;
}
}
}
let ceil = RoundingMode.ceil;
console.log(`${ceil}`); // "ceil"
let nf = new Intl.NumberFormat("en", { roundingMode: ceil });
console.log(nf.resolvedOptions().roundingMode); // "ceil" Without CoercionIf manual coercion is required, the call sites become the following, which I find as a library author to be less readable and more error-prone. I would rather write newtypes and enums that "just work". // Verbose Form:
let nf = new Intl.NumberFormat("en", { maximumFractionDigits: Number(five) });
let nf = new Intl.NumberFormat("en", { roundingMode: String(ceil) });
// Compact form to reduce code size:
let nf = new Intl.NumberFormat("en", { maximumFractionDigits: five|0 });
let nf = new Intl.NumberFormat("en", { roundingMode: ceil+"" }); |
Hm. Surely the relevant perspective on callsites is that of library users, not of authors, right? And as a library user (and reader of such code), I find the explicit coercions easier to understand than the implicit ones. The implicit coercions look like bugs: (Sidebar: the idiom for coercion to number I see most often is is |
I strongly agree with @bakkot as a code author; I enjoy a good type pun as much as the next person, but passing a string to an API that expects a string is just straightforward and readable. Many (tho not all) callsites in Python that expect a number or a string will throw if given the wrong thing, and I don't find I find relying on coercion to almost always sit on the "slightly too clever" side of things. Being slightly more explicit makes for better, more readable code imo, and the explicit conversion idioms ( |
FYI, these idioms are challenging for Temporal objects.
FWIW, I spent many weeks TypeScript is hopefully (microsoft/TypeScript#52773) going to make these problems easier to find by making it a compile error to use a built-in operator that calls |
Interesting. That's definitely a challenge, then. Tho, in my limited experience with Temporal objects, you don't really want to toString() them anyway, so probably not a huge deal. ^_^ But yeah, the |
I think the harms done by implicit coercion as the way we've designed standard APIs is pretty cut and dry: security bugs that pay out bounties, cost engineer time to fix, 0-day exploits, etc. The claimed upside is flexibility and ergonomics afforded to users of libraries, and library authors wanting to confer that flexibility. Both are good outcomes, and all other things being equal, we should trade off the upside of fewer security bugs and the downside of less flexibility towards the pole that is more impactful. My intuition is squarely that "fewer security bugs" is far more impactful as an outcome. My intuition is also that libraries or library users don't tend to want that flexibility, but I'd certainly be interested in seeing counterexamples. What I see seems to be that the ecosystem doesn't regularly use this flexibility, and that this flexibility is often deemed a smell or overly clever. |
Glad we're making these changes to enforce type safety. Thanks for pushing this. |
Co-authored-by: Ross Kirsling <[email protected]>
Co-authored-by: Michael Ficarra <[email protected]>
Co-authored-by: Michael Ficarra <[email protected]>
We got explicit consensus for these rules in the July 2023, September 2023, November 2023, and April 2024 meetings (notes not yet available for the April 2024).
#119 would introduce a
spec-conventions.md
. I think we shouldn't mix normative with editorial conventions, so I've made a different document. But the sole normative convention currently in that PR would be good to include in this new document.