Skip to content
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

NODE-2717: Improve TS Typings #389

Merged
merged 9 commits into from
Sep 15, 2020
Merged

NODE-2717: Improve TS Typings #389

merged 9 commits into from
Sep 15, 2020

Conversation

nbbeeken
Copy link
Contributor

@nbbeeken nbbeeken commented Sep 3, 2020

Improved the typings, a few any cases had to be left in to favor leaving code more or less untouched rather than refactoring.

*
* @classconstant BSON_BINARY_SUBTYPE_MD5
**/
export const BSON_BINARY_SUBTYPE_MD5 = 4;
Copy link
Contributor Author

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.

Comment on lines +32 to +36
export function isBSONType(value: unknown): value is BSONType {
return (
isObjectLike(value) && Reflect.has(value, '_bsontype') && typeof value._bsontype === 'string'
);
}
Copy link
Contributor Author

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}` 
  }
}

src/parser/deserializer.ts Show resolved Hide resolved
src/parser/serializer.ts Show resolved Hide resolved
Comment on lines +3 to +10
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';
Copy link
Contributor Author

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.

Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

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?

Copy link
Contributor Author

@nbbeeken nbbeeken Sep 11, 2020

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';
Copy link
Contributor

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: ... }?

Copy link
Contributor

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.

Copy link
Contributor Author

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 {
Copy link
Member

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?

Copy link
Contributor Author

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/bson.ts Outdated Show resolved Hide resolved
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;
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

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

src/code.ts Outdated Show resolved Hide resolved
src/constants.ts Outdated Show resolved Hide resolved
*/
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)) {
Copy link
Member

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);
Copy link
Member

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

Copy link
Member

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

src/parser/serializer.ts Show resolved Hide resolved
src/validate_utf8.ts Outdated Show resolved Hide resolved
Comment on lines +3 to +10
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';
Copy link
Member

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?

Copy link
Contributor Author

@nbbeeken nbbeeken left a 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/parser/deserializer.ts Show resolved Hide resolved
src/parser/deserializer.ts Show resolved Hide resolved
src/parser/deserializer.ts Show resolved Hide resolved
src/db_ref.ts Show resolved Hide resolved
src/db_ref.ts Outdated Show resolved Hide resolved
);
}

export type EJSONDocument = { [key: string]: ReturnType<BSONType['toExtendedJSON']> };
Copy link
Contributor Author

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 Show resolved Hide resolved
Double: o => new Double(o.value),
Int32: o => new Int32(o.value),
Long: o =>
Binary: (o: {
Copy link
Contributor Author

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.

Comment on lines +3 to +10
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';
Copy link
Contributor Author

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/validate_utf8.ts Outdated Show resolved Hide resolved
src/bson.ts Outdated Show resolved Hide resolved
src/db_ref.ts Outdated
collection: string;
oid: ObjectId;
db: string;
fields: any;
db?: string | null;
Copy link
Member

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/db_ref.ts Show resolved Hide resolved
Double: o => new Double(o.value),
Int32: o => new Int32(o.value),
Long: o =>
Binary: (o: {
Copy link
Member

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

src/parser/deserializer.ts Show resolved Hide resolved
Comment on lines +3 to +10
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';
Copy link
Member

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 {
Copy link
Member

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);
Copy link
Member

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

Copy link
Member

@mbroadst mbroadst left a 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;
Copy link
Member

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

@nbbeeken nbbeeken merged commit ae9ae2d into master Sep 15, 2020
@nbbeeken nbbeeken deleted the NODE-2717/improve-ts branch September 15, 2020 14:50
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.

4 participants