-
Notifications
You must be signed in to change notification settings - Fork 8.2k
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
Enforce SavedObject.attributes
to have a serializable object shape
#123605
Conversation
SavedObject.attributes
to have a serializable object shape
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 an attempt to add better type checking around the saved object attributes shape, to avoid the usage of types such as undefined
or javascript primitives or arrays.
As the SavedObject
type is used in a lot of places, this required to modify quite a lot of files to propagate the type changes.
To be honest, I'm not 100% sure these changes are really worth it, which is why I'd like to get the team's opinion on these changes.
Note: to address #123575, we'll add proper runtime validation for create
and bulkCreate
in another PR anyway.
@@ -66,7 +68,7 @@ export interface SavedObjectsMigrationVersion { | |||
[pluginName: string]: string; | |||
} | |||
|
|||
export interface SavedObject<T = unknown> { | |||
export interface SavedObject<T extends SavedObjectAttributes = SavedObjectAttributes> { |
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.
So this is the root change of the PR. We're no longer defaulting to unknown and we're enforcing that T
extends a serializable object/record, which will allow to detect misusages such as
soClient.create('dashboard', undefined, { id: 'test-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.
I've tried something like this long ago but ended up abandoning it.
A serializable type means that specific types like https://github.com/elastic/kibana/blob/main/src/plugins/dashboard/public/application/lib/load_dashboard_by_title.ts#L16 fail because they don't have an index signature. But if we change the type to have an index signature it becomes less type safe because it allows properties not specified by the plugin's own generic.
So ideally we want to maybe say, if you provide your own type we'll take that as is (without the index signature) but if you don't provide a type we'll use Serializable
because that's safer than unknown
. It also seems like this should only apply to create operations, because when reading unknown
is a safer type, and nudges plugins to create better types whereas Serializable
will allow any property access.
So I don't know if there's a way to always provide better type safety, but IMO we're improving some type safety but reducing it in other places.
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.
Yea, that's my feeling exactly.
There's no way to tell typescript that what we want as a type is an object WITHOUT index signature, without any property by default, and that can be extended, but only by objects having the correct type for their underlying properties (e.g serializable), which is a pain.
// type required for index signature | ||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions | ||
export type SavedQueryAttributes = { |
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 one of the side-effect of having T extends SavedObjectAttributes
. We're forced to use TS types instead of interfaces when overloading the default type of T
to avoid an index signature error.
interface SavedObjectAttributes {
[key: string]: SavedObjectAttribute;
}
interface SavedObject<T extends SavedObjectAttributes = SavedObjectAttributes> { ... }
// valid
let t: SavedObject<{ foo: string; bar: number }>
// valid but eslint error because we should be using an interface
type Foo = { foo: string; bar: number };
let t: SavedObject<Foo>;
// TS2344: Type 'Foo' does not satisfy the constraint 'SavedObjectAttributes'. Index signature for type 'string' is missing in type 'Foo'.
interface Foo { foo: string; bar: number };
let t: SavedObject<Foo>;
See microsoft/TypeScript#15300 for more details
it's not really blocker for the changes, but with our lint rules enforcing that we're using interfaces instead of types, this is awkward as it forces to add a linter exception for all type-based SO properties structures
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.
IMO interface Foo extends SavedObjectAttributes { ... }
should work.
However, there's the annoyance of importing the type everywhere. Also, it would allow devs to add additional non-typed keys because of the index signature 😕
I wonder if we should find an alternative to the current definition of SavedObjectAttributes
. The current index signature does not stop us from storing an empty saved object:
await context.core.savedObjects.client.create('my_type', {});
So, probably, defining SavedObjectAttributes
as type SavedObjectAttributes = object
is enough to ensure that an object is specified (vs. undefined
, null
or any other primitive), and we avoid the issue type
vs. interface
issue. WDYT?
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 hit a similar issue with persistable state interfaces and went on changing a bunch of interfaces to types
const response = (await this.savedObjectClient.find<T>(options)).savedObjects; | ||
return response.map<SavedObject<T>>(simpleSavedObjectToSavedObject); | ||
} | ||
|
||
async get<T = unknown>(type: string, id: string) { | ||
async get<T extends SavedObjectAttributes = SavedObjectAttributes>(type: string, id: 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 is the second thing a bit bothersome with that T extends SavedObjectAttributes = SavedObjectAttributes
type change. We can no longer use unknown
as a default for higher-level APIs when the shape of the attributes is not relevant, forcing to adapt all these higher-level APIs to also define the correct generic types.
Pinging @elastic/kibana-core (Team:Core) |
💔 Build FailedFailed CI StepsMetrics [docs]
History
To update your PR or re-run it, just comment with: |
@@ -115,7 +115,6 @@ export type { | |||
SavedObject, | |||
SavedObjectAttribute, | |||
SavedObjectAttributes, | |||
SavedObjectAttributeSingle, |
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.
NIT: Do we need to run yarn docs:acceptApiChanges --docs
?
@@ -533,7 +548,9 @@ export class SavedObjectsClient { | |||
}; | |||
}; | |||
|
|||
private async performBulkResolve<T>(objects: ObjectTypeAndId[]) { | |||
private async performBulkResolve<T extends SavedObjectAttributes = SavedObjectAttributes>( |
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.
nit: Previously, T
wasn't optional. Should we keep it that way?
@@ -176,7 +176,10 @@ export interface SavedObjectsResolveImportErrorsOptions { | |||
createNewCopies: boolean; | |||
} | |||
|
|||
export type CreatedObject<T> = SavedObject<T> & { destinationId?: string }; | |||
export type CreatedObject<T extends SavedObjectAttributes = SavedObjectAttributes> = |
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.
Are we OK with making T
optional when it was required?
@@ -11,7 +11,7 @@ import { SavedObjectsImportFailure, CreatedObject } from '../types'; | |||
|
|||
export function extractErrors( | |||
// TODO: define saved object type | |||
savedObjectResults: Array<CreatedObject<unknown>>, | |||
savedObjectResults: CreatedObject[], | |||
savedObjectsToImport: Array<SavedObject<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.
nit: Removing any
s
savedObjectsToImport: Array<SavedObject<any>> | |
savedObjectsToImport: Array<SavedObject<{ title?: string }>> |
@@ -33,7 +22,7 @@ export type SavedObjectAttribute = SavedObjectAttributeSingle | SavedObjectAttri | |||
* @public | |||
*/ | |||
export interface SavedObjectAttributes { | |||
[key: string]: SavedObjectAttribute; | |||
[key: string]: Serializable; |
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 change is not needed. Is it?
// type required for index signature | ||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions | ||
export type SavedQueryAttributes = { |
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.
IMO interface Foo extends SavedObjectAttributes { ... }
should work.
However, there's the annoyance of importing the type everywhere. Also, it would allow devs to add additional non-typed keys because of the index signature 😕
I wonder if we should find an alternative to the current definition of SavedObjectAttributes
. The current index signature does not stop us from storing an empty saved object:
await context.core.savedObjects.client.create('my_type', {});
So, probably, defining SavedObjectAttributes
as type SavedObjectAttributes = object
is enough to ensure that an object is specified (vs. undefined
, null
or any other primitive), and we avoid the issue type
vs. interface
issue. WDYT?
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.
app services changes LGTM
After discussing it with @rudolf we came to the conclusion that this wasn't worth it because of the TS limitation around what we're trying to achieve (#123605 (comment)). Closing |
Summary
Part of #123575
This PR is an attempt to enforce better attributes type for the
SavedObject
type and all the related APIs.Note: this PR only impact typescript types and interfaces. The real runtime validation of the shape of the attributes when calling
create
/bulkCreate
will be done in another PR.Checklist