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

feat(types): add InferAttributes utility type #13909

Merged
merged 29 commits into from Jan 21, 2022
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f8dfa3b
feat(types): add `AttributesOf` utility type
ephys Jan 5, 2022
0351311
docs: document & typecheck `AttributesOf`
ephys Jan 5, 2022
74a3248
Merge branch 'main' into feature/attributesof
ephys Jan 5, 2022
f895c87
docs: make comments more explicit
ephys Jan 5, 2022
271e392
docs: hide maintainer-only information in typescript.md
ephys Jan 5, 2022
3f833fc
fix(types): revert accidental removal of Fn import
ephys Jan 5, 2022
75f17c4
feat(types): add `CreationAttributesOf`
ephys Jan 7, 2022
a55c39f
fix(types): make `undefined` fields optional in CreationAttributes
ephys Jan 7, 2022
932e618
feat(types): merge (Creation)AttributesOf, drop branded type
ephys Jan 7, 2022
11bcaf6
feat(types): bring back CreationOptional now that I understand branding
ephys Jan 7, 2022
7896f96
feat(types): add `NonAttribute` branded type
ephys Jan 7, 2022
60589bc
docs(types): document new attribute declaration system
ephys Jan 7, 2022
b4adc2a
fix(types): fix branded array support
ephys Jan 7, 2022
0922b2f
Merge branch 'main' into feature/attributesof
ephys Jan 7, 2022
a3bbc84
fix(types): fix array brand typing again
ephys Jan 7, 2022
7be44b2
Merge branch 'main' into feature/attributesof
ephys Jan 7, 2022
8d92e03
Merge branch 'main' into feature/attributesof
ephys Jan 8, 2022
17b7699
feat(types): add Attributes/CreationAttributes, rename AttributesOf
ephys Jan 8, 2022
1da9ce3
docs(typescript): add missing methods / remove readonly
ephys Jan 8, 2022
120e244
Merge branch 'main' into feature/attributesof
ephys Jan 11, 2022
2ffe137
Merge branch 'main' into feature/attributesof
ephys Jan 16, 2022
9e66864
Merge branch 'main' into feature/attributesof
ephys Jan 16, 2022
644afbe
refactor: add unused catch binding to workaround esbuild issue
ephys Jan 16, 2022
7c908e3
refactor: add unused catch binding to workaround esbuild issue
ephys Jan 16, 2022
7b97c0d
refactor: make esdocs able to compile typescript.md
ephys Jan 16, 2022
853926c
refactor: implement review changes
ephys Jan 17, 2022
21b8533
Merge branch 'main' into feature/attributesof
ephys Jan 21, 2022
e518b01
docs: specify InferAttributes release version + fix type names
ephys Jan 21, 2022
2706b51
docs: document TypeScript utility types
ephys Jan 21, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
233 changes: 223 additions & 10 deletions docs/manual/other-topics/typescript.md
Expand Up @@ -18,12 +18,9 @@ Example of a minimal TypeScript project with strict type-checking for attributes
**Important**: You must use `declare` on your class properties typings to ensure TypeScript does not emit those class properties.
See [Caveat with Public Class Fields](./model-basics.html#caveat-with-public-class-fields)

**NOTE:** Keep the following code in sync with `/types/test/typescriptDocs/ModelInit.ts` to ensure it typechecks correctly.
<!-- NOTE for maintainers: Keep the following code in sync with `/types/test/typescriptDocs/ModelInit.ts` to ensure it typechecks correctly. -->

```typescript
/**
* Keep this file in sync with the code in the "Usage" section in typescript.md
*/
import {
Association, DataTypes, HasManyAddAssociationMixin, HasManyCountAssociationsMixin,
HasManyCreateAssociationMixin, HasManyGetAssociationsMixin, HasManyHasAssociationMixin, Model,
Expand Down Expand Up @@ -247,8 +244,8 @@ async function doStuffWithUser() {

The typings for Sequelize v5 allowed you to define models without specifying types for the attributes. This is still possible for backwards compatibility and for cases where you feel strict typing for attributes isn't worth it.

**NOTE:** Keep the following code in sync with `typescriptDocs/ModelInitNoAttributes.ts` to ensure
it typechecks correctly.
<!-- NOTE for maintainers: Keep the following code in sync with `typescriptDocs/ModelInitNoAttributes.ts` to ensure
it typechecks correctly. -->

```ts
import { Sequelize, Model, DataTypes } from "sequelize";
Expand Down Expand Up @@ -296,12 +293,228 @@ async function doStuffWithUserModel() {
}
```

### Using with `AttributesOf`

`AttributeOf` is a utility type you can use to automatically extract attributes from your class definition.
It's an alternative solution designed to reduce the boilerplate of the above solution.

Some caveats of this solution:

- it's not possible to use `AttributesOf` for `CreationAttributes`.
This example opted for making properties that are not set until saved always optional instead.
- `AttributesOf` cannot whether a field is a getter, so they are not excluded by default. Use the second parameter to exclude getters.
eg. `AttributesOf<User, 'projects'>` will exclude the field "projects" from the attribute list.
- `AttributesOf` excludes fields that are inherited from `Model`.
If one of your attributes shares the same name as one of the properties of `Model`, change its name.
Doing this is likely to cause issues anyway.

Here is the same example as above, but using `AttributeOf`.

<!-- NOTE for maintainers: Keep the following code in sync with `/types/test/typescriptDocs/ModelInitWithAttributesOf.ts` to ensure it typechecks correctly. -->

```typescript
import {
Association, DataTypes, HasManyAddAssociationMixin, HasManyCountAssociationsMixin,
HasManyCreateAssociationMixin, HasManyGetAssociationsMixin, HasManyHasAssociationMixin, Model,
ModelDefined, Optional, Sequelize, AttributesOf
} from 'sequelize';

const sequelize = new Sequelize('mysql://root:asd123@localhost:3306/mydb');

// 'projects' is excluded as it's not an attribute, it's an association.
class User extends Model<AttributesOf<User, 'projects'>> {
// id can be undefined during creation when using `autoIncrement`
declare id?: number;
ephys marked this conversation as resolved.
Show resolved Hide resolved
declare name: string;
declare preferredName: string | null; // for nullable fields

// timestamps!
// createdAt can be undefined during creation
declare readonly createdAt?: Date;
// updatedAt can be undefined during creation
declare readonly updatedAt?: Date;
ephys marked this conversation as resolved.
Show resolved Hide resolved

// Since TS cannot determine model association at compile time
// we have to declare them here purely virtually
// these will not exist until `Model.init` was called.
declare getProjects: HasManyGetAssociationsMixin<Project>; // Note the null assertions!
declare addProject: HasManyAddAssociationMixin<Project, number>;
declare hasProject: HasManyHasAssociationMixin<Project, number>;
declare countProjects: HasManyCountAssociationsMixin;
declare createProject: HasManyCreateAssociationMixin<Project, 'ownerId'>;
ephys marked this conversation as resolved.
Show resolved Hide resolved

// You can also pre-declare possible inclusions, these will only be populated if you
// actively include a relation.
declare readonly projects?: Project[]; // Note this is optional since it's only populated when explicitly requested in code

declare static associations: {
projects: Association<User, Project>;
};
}

// You can write `extends Model<AttributesOf<Project>, AttributesOf<Project>>` instead,
// but that will do the exact same thing as below
class Project extends Model<AttributesOf<Project>> {
// id can be undefined during creation when using `autoIncrement`
declare id?: number;
declare ownerId: number;
declare name: string;

// createdAt can be undefined during creation
declare readonly createdAt?: Date;
// updatedAt can be undefined during creation
declare readonly updatedAt?: Date;
}

class Address extends Model<AttributesOf<Address>> {
declare userId: number;
declare address: string;

// createdAt can be undefined during creation
declare readonly createdAt?: Date;
// updatedAt can be undefined during creation
declare readonly updatedAt?: Date;
}

// You can also define modules in a functional way
interface NoteAttributes {
id: number;
title: string;
content: string;
}

// You can also set multiple attributes optional at once
interface NoteCreationAttributes
extends Optional<NoteAttributes, 'id' | 'title'> {
}

Project.init(
{
id: {
type: DataTypes.INTEGER.UNSIGNED,
autoIncrement: true,
primaryKey: true
},
ownerId: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false
},
name: {
type: new DataTypes.STRING(128),
allowNull: false
},
},
{
sequelize,
tableName: 'projects'
}
);

User.init(
{
id: {
type: DataTypes.INTEGER.UNSIGNED,
autoIncrement: true,
primaryKey: true
},
name: {
type: new DataTypes.STRING(128),
allowNull: false
},
preferredName: {
type: new DataTypes.STRING(128),
allowNull: true
}
},
{
tableName: 'users',
sequelize // passing the `sequelize` instance is required
}
);

Address.init(
{
userId: {
type: DataTypes.INTEGER.UNSIGNED
},
address: {
type: new DataTypes.STRING(128),
allowNull: false
}
},
{
tableName: 'address',
sequelize // passing the `sequelize` instance is required
}
);

// And with a functional approach defining a module looks like this
const Note: ModelDefined<NoteAttributes,
NoteCreationAttributes> = sequelize.define(
'Note',
{
id: {
type: DataTypes.INTEGER.UNSIGNED,
autoIncrement: true,
primaryKey: true
},
title: {
type: new DataTypes.STRING(64),
defaultValue: 'Unnamed Note'
},
content: {
type: new DataTypes.STRING(4096),
allowNull: false
}
},
{
tableName: 'notes'
}
);

// Here we associate which actually populates out pre-declared `association` static and other methods.
User.hasMany(Project, {
sourceKey: 'id',
foreignKey: 'ownerId',
as: 'projects' // this determines the name in `associations`!
});

Address.belongsTo(User, { targetKey: 'id' });
User.hasOne(Address, { sourceKey: 'id' });

async function doStuffWithUser() {
const newUser = await User.create({
name: 'Johnny',
preferredName: 'John',
});
console.log(newUser.id, newUser.name, newUser.preferredName);

const project = await newUser.createProject({
name: 'first!'
});

const ourUser = await User.findByPk(1, {
include: [User.associations.projects],
rejectOnEmpty: true // Specifying true here removes `null` from the return type!
});

// Note the `!` null assertion since TS can't know if we included
// the model or not
console.log(ourUser.projects![0].name);
}

(async () => {
await sequelize.sync();
await doStuffWithUser();
})();
```

## Usage of `sequelize.define`

In Sequelize versions before v5, the default way of defining a model involved using `sequelize.define`. It's still possible to define models with that, and you can also add typings to these models using interfaces.

**NOTE:** Keep the following code in sync with `typescriptDocs/Define.ts` to ensure
it typechecks correctly.
<!-- NOTE for maintainers: Keep the following code in sync with `typescriptDocs/Define.ts` to ensure
it typechecks correctly. -->

```ts
import { Sequelize, Model, DataTypes, Optional } from "sequelize";
Expand Down Expand Up @@ -342,8 +555,8 @@ async function doStuff() {

If you're comfortable with somewhat less strict typing for the attributes on a model, you can save some code by defining the Instance to just extend `Model` without any attributes in the generic types.

**NOTE:** Keep the following code in sync with `typescriptDocs/DefineNoAttributes.ts` to ensure
it typechecks correctly.
<!-- NOTE for maintainers: Keep the following code in sync with `typescriptDocs/DefineNoAttributes.ts` to ensure
it typechecks correctly. -->

```ts
import { Sequelize, Model, DataTypes } from "sequelize";
Expand Down
40 changes: 40 additions & 0 deletions types/lib/model.d.ts
Expand Up @@ -2926,3 +2926,43 @@ export type ModelDefined<S, T> = ModelStatic<Model<S, T>>;
export type ModelStatic<M extends Model> = NonConstructor<typeof Model> & { new(): M };

export default Model;

/**
* Utility type to extract Attributes of a given Model.
*
* It returns all instance properties defined in the Model, except:
* - those inherited from Model (intermediate inheritance works),
* - the ones whose type is a function,
* - the ones manually excluded using the second parameter.
*
* It cannot detect whether something is a getter or not, you should use the `Excluded`
* parameter to exclude getter & setters from the attribute list.
*
* @example
* // listed attributes will be 'id' & 'firstName'.
* class User extends Model<AttributesOf<User>> {
* id: number;
* firstName: string;
* }
*
* @example
* // listed attributes will be 'id' & 'firstName'.
* // we're excluding the `name` & `test` getters using the second argument.
* class User extends Model<AttributesOf<User, 'name' | 'test'>> {
* id: number;
* firstName: string;
*
* get name() { return this.firstName; }
* get test() { return ''; }
* }
*/
export type AttributesOf<M extends Model, Excluded extends string = ''> = {
[Key in keyof M as
M[Key] extends AnyFunction ? never
: Key extends keyof Model ? never
: Key extends Excluded ? never
: Key
]: M[Key]
};

type AnyFunction = (...args: any[]) => any;
4 changes: 3 additions & 1 deletion types/test/typescriptDocs/Define.ts
@@ -1,6 +1,8 @@
/**
* Keep this file in sync with the code in the "Usage of `sequelize.define`"
* section in typescript.md
* section in /docs/manual/other-topics/typescript.md
*
* Don't include this comment in the md file.
*/
import { Sequelize, Model, DataTypes, Optional } from 'sequelize';

Expand Down
4 changes: 3 additions & 1 deletion types/test/typescriptDocs/DefineNoAttributes.ts
@@ -1,6 +1,8 @@
/**
* Keep this file in sync with the code in the "Usage of `sequelize.define`"
* that doesn't have attribute types in typescript.md
* that doesn't have attribute types in /docs/manual/other-topics/typescript.md
*
* Don't include this comment in the md file.
*/
import { Sequelize, Model, DataTypes } from 'sequelize';

Expand Down
5 changes: 4 additions & 1 deletion types/test/typescriptDocs/ModelInit.ts
@@ -1,5 +1,8 @@
/**
* Keep this file in sync with the code in the "Usage" section in typescript.md
* Keep this file in sync with the code in the "Usage" section
* in /docs/manual/other-topics/typescript.md
*
* Don't include this comment in the md file.
*/
import {
Association, DataTypes, HasManyAddAssociationMixin, HasManyCountAssociationsMixin,
Expand Down
4 changes: 3 additions & 1 deletion types/test/typescriptDocs/ModelInitNoAttributes.ts
@@ -1,6 +1,8 @@
/**
* Keep this file in sync with the code in the "Usage without strict types for
* attributes" section in typescript.md
* attributes" section in /docs/manual/other-topics/typescript.md
*
* Don't include this comment in the md file.
*/
import { Sequelize, Model, DataTypes } from 'sequelize';

Expand Down