-
Notifications
You must be signed in to change notification settings - Fork 258
NODE-2717: Improve TS Typings #389
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
Conversation
* | ||
* @classconstant BSON_BINARY_SUBTYPE_MD5 | ||
**/ | ||
export const BSON_BINARY_SUBTYPE_MD5 = 4; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't use this anywhere internally, but we do export it.
I added tests to make sure we have the correct values, who knows typos happen best to guard against that.
export function isBSONType(value: unknown): value is BSONType { | ||
return ( | ||
isObjectLike(value) && Reflect.has(value, '_bsontype') && typeof value._bsontype === 'string' | ||
); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This makes for some very nice TS
if (isBSONType(x)) {
x._bsontype // union of all the type names
if(x._bsontype === 'Double') {
x.toExtendedJSON() // function exists and return type can be narrowed to `{$numberDouble: string}`
}
}
type TimestampOverrides = '_bsontype' | 'toExtendedJSON' | 'fromExtendedJSON'; | ||
type LongWithoutOverrides = new (low: number | Long, high?: number, unsigned?: boolean) => { | ||
[P in Exclude<keyof Long, TimestampOverrides>]: Long[P]; | ||
}; | ||
const LongWithoutOverridesClass: LongWithoutOverrides = (Long as unknown) as LongWithoutOverrides; | ||
|
||
export class Timestamp extends LongWithoutOverridesClass { | ||
_bsontype!: 'Timestamp'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know what you're thinking.. this looks gross! 😅
This is the only case where we extend from another BSON type but this means we can't just have different types for _bsontype
and from/toExtendedJSON so this does some trickery to create a Long type that doesn't have those properties. The generated code is not effected it still extends Long as normal.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another way to accomplish this would be to make something like:
class LongBase { ... the vendored Long implementation ... }
export class Long extends LongBase {
_bsontype!: 'Long';
fromExtendedJSON(..) {}
toExtendedJSON(..) {}
}
export class Timestamp extends LongBase {
_bsontype!: 'Timestamp';
fromExtendedJSON(..) {}
toExtendedJSON(..) {}
}
This approach might make it easier to use BigInt
in the future as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a good solution!
But there's a type issues that arise in a few places, namely Long.isLong asserts that something is LongBase
and then we would need a further check to see if something is Long with _bsontype
My solution lives only in TS land and doesn't effect code output. I know its a hack kinda but it feels like the right way to go to keep types consistent.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looks like the definitely typed typings use a similar approach to what I suggested?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh yea I could paramertize the Long class, I'll look into that.
Actually to make this work you have to duplicate the static methods on each class, since static things don't have access to the class type parameters. It's easy in the ambient context to just duplicate the function headers but in real code that mean actually copy pasting a bunch of bitwise shifting code on to two classes 😅
* @ignore | ||
*/ | ||
toExtendedJSON() { | ||
_bsontype!: 'MaxKey'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this necessary? If so shouldn't it be { value: ... }
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed this around a lot, would love to learn more about the difference between this and the prototype
addition to the class. Could just be _bsontype = { value: "MaxKey" }
curious why not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We still want it to be a string so we would want it to be _bsontype = "MaxKey"
but defining it this way makes it an editable property on the MaxKey instance and not on its prototype. Also by defining it with a call to Object.defineProperty
by default the property descriptor is
{
value: 'MaxKey',
writable: false,
enumerable: false,
configurable: false
}
So it is neither writable, enumerable, nor configurable. In addition to this, and I could be mistaken but the prototype is not editable outside the module so you can't change this once its exported which is good for security.
src/bson.ts
Outdated
@@ -85,13 +88,12 @@ export const EJSON = { | |||
deserialize: EJSON_deserialize | |||
}; | |||
|
|||
export interface Document { | |||
export interface BSONDocument { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's motivating this change?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since there are DOM types here Document is the type of the global document
object. We can rename this in the driver import but it seems best that this library doesn't clash.
src/code.ts
Outdated
* A class representation of the BSON Code type. | ||
*/ | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
export type CodeFunction = (...args: any[]) => any; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we just use Function
instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there something confusing about this type?
We can Function just throws warnings which I can silence, maybe even globally in the config but if not any funciton that works with this type will have to be annotated. This accepts any function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, I don't know where my comment to this went.. I'm wondering because in spirit the Code
type is meant to be a wrapper around a JavaScript function, so it seemed weird to me that we would introduce a new type which accepts any function rather than just using the primitive type.
Maybe we can disable the Function
rule just for the files where we need to use it? When it comes to BSON there will have to be exceptions, we already disable linter rules for any
in a number of key places because.. well you need to support any
sometimes! I'd rather not introduce new types just to get around a linter warning, if it's going to make the code less readable
*/ | ||
toString(format?: BufferEncoding): string { | ||
// Is the id a buffer then use the buffer toString method to return the format | ||
if (this.id && 'copy' in (this.id as any)) { | ||
if (this.id && typeof this.id !== 'string' && 'copy' in (this.id as Buffer)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why the added check for string
?
|
||
// The most common use case (blank id, new objectId instance) | ||
if (id == null || typeof id === 'number') { | ||
// Generate a new id | ||
this.id = ObjectId.generate(id); | ||
this.id = ObjectId.generate(typeof id === 'number' ? id : undefined); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
now I see where all the checks for string
are coming from. Let's solve this once and for all, the type for id
should just be a Buffer
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was lost as well, we can do it in follow-on work but please make a ticket for that
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
type TimestampOverrides = '_bsontype' | 'toExtendedJSON' | 'fromExtendedJSON'; | ||
type LongWithoutOverrides = new (low: number | Long, high?: number, unsigned?: boolean) => { | ||
[P in Exclude<keyof Long, TimestampOverrides>]: Long[P]; | ||
}; | ||
const LongWithoutOverridesClass: LongWithoutOverrides = (Long as unknown) as LongWithoutOverrides; | ||
|
||
export class Timestamp extends LongWithoutOverridesClass { | ||
_bsontype!: 'Timestamp'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another way to accomplish this would be to make something like:
class LongBase { ... the vendored Long implementation ... }
export class Long extends LongBase {
_bsontype!: 'Long';
fromExtendedJSON(..) {}
toExtendedJSON(..) {}
}
export class Timestamp extends LongBase {
_bsontype!: 'Timestamp';
fromExtendedJSON(..) {}
toExtendedJSON(..) {}
}
This approach might make it easier to use BigInt
in the future as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My orignal comments got lost in a browser refresh, please bare with the terseness of these as I tried to quickly make up for lost time.
src/extended_json.ts
Outdated
); | ||
} | ||
|
||
export type EJSONDocument = { [key: string]: ReturnType<BSONType['toExtendedJSON']> }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes! unfortunately it also includes the relaxed return types as well, I wish there was a way to seperate them but this is a good start!
src/extended_json.ts
Outdated
Double: o => new Double(o.value), | ||
Int32: o => new Int32(o.value), | ||
Long: o => | ||
Binary: (o: { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Binary has sub_type
and this accepts subtype
, they're not compatible types.
type TimestampOverrides = '_bsontype' | 'toExtendedJSON' | 'fromExtendedJSON'; | ||
type LongWithoutOverrides = new (low: number | Long, high?: number, unsigned?: boolean) => { | ||
[P in Exclude<keyof Long, TimestampOverrides>]: Long[P]; | ||
}; | ||
const LongWithoutOverridesClass: LongWithoutOverrides = (Long as unknown) as LongWithoutOverrides; | ||
|
||
export class Timestamp extends LongWithoutOverridesClass { | ||
_bsontype!: 'Timestamp'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a good solution!
But there's a type issues that arise in a few places, namely Long.isLong asserts that something is LongBase
and then we would need a further check to see if something is Long with _bsontype
My solution lives only in TS land and doesn't effect code output. I know its a hack kinda but it feels like the right way to go to keep types consistent.
src/db_ref.ts
Outdated
collection: string; | ||
oid: ObjectId; | ||
db: string; | ||
fields: any; | ||
db?: string | null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
did you make this change? It looks the same here, should just be db?: string
right?
src/extended_json.ts
Outdated
Double: o => new Double(o.value), | ||
Int32: o => new Int32(o.value), | ||
Long: o => | ||
Binary: (o: { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oof, that's unintentional I'm sure and probably a bug (it was never called subtype
, even in 0.2.x)! My comment generally applies to this whole section though
type TimestampOverrides = '_bsontype' | 'toExtendedJSON' | 'fromExtendedJSON'; | ||
type LongWithoutOverrides = new (low: number | Long, high?: number, unsigned?: boolean) => { | ||
[P in Exclude<keyof Long, TimestampOverrides>]: Long[P]; | ||
}; | ||
const LongWithoutOverridesClass: LongWithoutOverrides = (Long as unknown) as LongWithoutOverrides; | ||
|
||
export class Timestamp extends LongWithoutOverridesClass { | ||
_bsontype!: 'Timestamp'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looks like the definitely typed typings use a similar approach to what I suggested?
@@ -42,10 +33,17 @@ function makeObjectIdError(invalidString, index) { | |||
); | |||
} | |||
|
|||
export interface ObjectIdLike { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looks like this comment was lost in the page refresh
|
||
// The most common use case (blank id, new objectId instance) | ||
if (id == null || typeof id === 'number') { | ||
// Generate a new id | ||
this.id = ObjectId.generate(id); | ||
this.id = ObjectId.generate(typeof id === 'number' ? id : undefined); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was lost as well, we can do it in follow-on work but please make a ticket for that
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM! I left one comment about CodeFunction
that I feel like 4/10 about, feel free to just merge if you strongly disagree with my position.
src/code.ts
Outdated
* A class representation of the BSON Code type. | ||
*/ | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
export type CodeFunction = (...args: any[]) => any; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, I don't know where my comment to this went.. I'm wondering because in spirit the Code
type is meant to be a wrapper around a JavaScript function, so it seemed weird to me that we would introduce a new type which accepts any function rather than just using the primitive type.
Maybe we can disable the Function
rule just for the files where we need to use it? When it comes to BSON there will have to be exceptions, we already disable linter rules for any
in a number of key places because.. well you need to support any
sometimes! I'd rather not introduce new types just to get around a linter warning, if it's going to make the code less readable
Improved the typings, a few any cases had to be left in to favor leaving code more or less untouched rather than refactoring.