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 3 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
219 changes: 219 additions & 0 deletions docs/manual/other-topics/typescript.md
Expand Up @@ -296,6 +296,225 @@ 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.

Here is the same example as above, but using `AttributeOf`.
WikiRik marked this conversation as resolved.
Show resolved Hide resolved

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.

**NOTE:** Keep the following code in sync with `/types/test/typescriptDocs/ModelInitWithAttributesOf.ts` to ensure it typechecks correctly.
WikiRik marked this conversation as resolved.
Show resolved Hide resolved

```typescript
/**
* Keep this file in sync with the code in the "Using with `AttributesOf`" section in typescript.md
WikiRik marked this conversation as resolved.
Show resolved Hide resolved
*/
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'>> {
// can be undefined during creation when using `autoIncrement`
ephys marked this conversation as resolved.
Show resolved Hide resolved
declare id?: number;
ephys marked this conversation as resolved.
Show resolved Hide resolved
declare name: string;
declare preferredName: string | null; // for nullable fields

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

// 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;

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

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

// can be undefined during creation
ephys marked this conversation as resolved.
Show resolved Hide resolved
declare readonly createdAt?: Date;
// can be undefined during creation
ephys marked this conversation as resolved.
Show resolved Hide resolved
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.
Expand Down
42 changes: 41 additions & 1 deletion types/lib/model.d.ts
Expand Up @@ -7,7 +7,7 @@ import { ValidationOptions } from './instance-validator';
import { IndexesOptions, QueryOptions, TableName } from './query-interface';
import { Sequelize, SyncOptions } from './sequelize';
import { LOCK, Transaction } from './transaction';
import { Col, Fn, Literal, Where } from './utils';
import { Col, Literal, Where } from './utils';
import { SetRequired } from '../type-helpers/set-required'
import Op from '../../lib/operators';

Expand Down 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;