microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
101.29k stars 12.53k forks source link

Safe decorator implementation with ^ type operator. #38620

Open samchon opened 4 years ago

samchon commented 4 years ago

Outline

Nowadays, lots of famous JavaScript libraries like typeorm or nestjs are supporting the decorator feature. However, the decorator feature is not suitable for core philosophy of the TypeScript; the safe implementation.

When defining a decorator onto a variable, duplicated definition in the variable type must be written. It's very annoying and even dangerous when different type be defined in the variable type. The TypeScript compiler can't detect the mis-typed definition.

For an example, look at the below code, then you may find something weird. Right, column types of title and content are varchar and text. However, their instance types are number and boolean. Their instance type must be string, but there would not be any compile error when using the TypeScript. It's the dangerous characteristic of the decorator what I want to say.

@Table()
export class BbsArticle extends Model<BbsArticle>
{
    @Column("varchar", {
        restrict: ["NORMAL", "NOTICE", "QUESTION", "SUGGESTION"],
        default: "NORMAL"
    })
    public category!: "NORMAL"|"NOTICE"|"QUESTION"|"SUGGESTION";

    @Column("varchar")
    public title!: number;

    @Column("varchar", { nullable: true })
    public sub_title!: string | null;

    @Column("text")
    public content!: boolean;

    @Column("int", { unsigned: true, default: 1 })
    public hits!: number;
}

function Column<Type extends TypeList, Options extends Options<Type>>
    (type: string, options?: Options<Type>): SomeFunction;

I think such dangerous characteristic is the reason why TypeScript is supporting the decorator as experimental feature for a long time. I want to suggest an alternative solution that can make decorator to be much safer, so that TypeScipt can adapt the decorator as a standard feature.

Key of the alternative solution is to defining the decorator feature not in front of the variable, but in the variable type definition part. To implement the variable type defined decarator, I think a new pseudo type operator ^ is required.

@Table()
export class BbsArticle extends Model<BbsArticle>
{
    // ("NORMAL"|"NOTICE"|"QUESTION"|"SUGGESTION") ^ SomeFunction
    public category!: @Column("varchar", {
        restrict: ["NORMAL", "NOTICE", "QUESTION", "SUGGESTION"],
        default: "NORMAL"
    });

    // string ^ SomeFunction
    public title!: @Column("varchar");

    // (string | null) ^ SomeFunction
    public sub_title!: @Column("varchar", { nullable: true });

    // string ^ SomeFunction
    public content!: @Column("text");

    // number ^ SomeFunction
    public hits!: @Column("int", { 
        unsigned: true, 
        default: 1 
    });
}

function Column<Type extends TypeList, Options extends Options<Type>>
    (type: Type, options?: Options): DeductType<Type, Options> ^ SomeFunction;

Pseudo type operator ^

// Decorator is a function returning a function.
function SomeDecorator(): Function;

// Variable type cannot be expressed
var someVariable: @SomeDecorator();

In JavaScript, decorator is a type of function returning a meta function. In the ordinary TypeScript, the decorator function would be represented like upper code. Therefore, there's no way to express the variable type who're using the decorator.

Therefore, I suggest a new pseudo type operator, ^ symbol. With the ^ symbol, expressing both variable and decorator types, at the same time, are possible. Left side of the ^ operator would be the variable type and that can be assigned or be read as a value. The right side would be a pseudo type representing return type of the target decorator.

Within framework of the type meta programming, both left and right side of the ^ symbol can be all used. Extending types from both left and right side are all possible. However, assigning and reading variable's value, it's only permitted to the left side's type.

function SomeDecorator(): number ^ Function
{
    return function ()
    {
        // implementation code
    };
}
type SomeType = ReturnType<SomeDecorator>;

// TYPE EXTENSIONS ARE ALL POSSIBLE
type ExtendsNumber = SomeType extends number ? true : false; // true
type ExtendsColumn = SomeType extends Function ? true : false; // true

// ASSIGNING VALUE IS POSSIBLE
let x: SomeType = 3; // no error

// ASSIGNING THE DECORATOR FUNCTION IS NOT POSSIBLE
let decorator: Function = () => {};
x = decorator; // be compile error

Appendix

ORM Components

If the safe decorator implementation with the new ^ symbol is realized, there would be revolutionary change in ORM components. TypeScript would be the best programming language who can represents database table exactly through the ORM component and the safe decorator implementation.

It would be possible to represent the exact columns only with decorators. Duplicated definitions on the member variable types, it's not required any more. Just read the below example ORM code, and feel which revolution would come:

@Table()
export class BbsArticle
    extends Model<BbsArticle>
{
    /* -----------------------------------------------------------
        COLUMNS
    ----------------------------------------------------------- */
    // number ^ IncrementalColumn<"int">
    public readonly id!: @IncrementalColumn("int");

    // number ^ ForeignColumn<ForeignColumn>
    public bbs_group_id!: @ForeignColumn(() => BbsGroup);

    // (number | null) ^ ForeignColumn<BbsArticle, Options>
    public parent_article_id!: @ForeignColumn(() => BbsArticle, { 
        index: true, 
        nullable: true 
    });

    // ("NORMAL"|"NOTICE"|"QUESTION"|"SUGGESTION") ^ Column<"int", Options>
    public category!: @Column("varchar", {
        restrict: ["NORMAL", "NOTICE", "QUESTION", "SUGGESTION"],
        default: "NORMAL"
    });

    // string ^ Column<"varchar", Options>
    public writer!: @Column("varchar", {
        index: true, 
        default: () => Random.characters(16) 
    });

    // string ^ Column<"varchar">
    public password!: @Column("varchar");

    // string ^ Column<"varchar">
    public title!: @Column("varchar");

    // (string | null) & Column<"varchar", Options>
    public sub_title!: @Column("varchar", {
        nullable: true 
    }); 

    // string ^ Column<"text">
    public content!: @Column("text");

    // number ^ Column<"int", Options>
    public hits!: @Column("int", {
        unsigned: true,
        default: 0
    });

    // Date ^ CreationTimeColumn
    public created_at!: @CreationTimeColumn();

    // (Date | null) ^ UpdationTimeColumn
    public updated_at!: @UpdationTimeColumn();

    // (Date | null) ^ SoftDeletionColumn
    public deleted_at!: @SoftDeletionColumn();

    /* -----------------------------------------------------------
        RELATIONSHIPS
    ----------------------------------------------------------- */
    public getGroup(): Promise<BbsGroup>
    {
        return this.belongsTo(BbsGroup, "bbs_group_id");
    }

    public getParent(): Promise<BbsArticle | null>
    {
        return this.belongsto(BbsArticle, "parent_article_id");
    }

    public getChildren(): Promise<BbsArticle[]>
    {
        return this.hasMany(BbsArticle, "parent_article_id");
    }

    public getTags(): Promise<BbsTag[]>
    {
        return this.hasManyToMany(BbsTag, BbsArticleTag, "bbs_tag_id", "bbs_article_id");
    }
}

If this issue be adopted, so that the safe decorator implementation with the ^ symbol is realized in the future TypeScript compiler, even join relationship can be much safer.

Because foreign columns are defined with the safe decorator, member variables of the columns have exact information about the reference. Therefore, defining join relationship can be safe with type meta programming like below:

export abstract class Model<Entity extends Model<Entity>>
{
    protected async belongsTo<
            Target extends Model<Target>, 
            Field extends SpecialFields<Entity, ForeignColumn<Target>>>
        (target: CreatorType<Target>, field: Field): 
            Promise<Entity[Field] extends ForeignColumn<Target, { nullable: true }>
                ? Target | null
                : Target>;

    protected async hasOne<
            Target extends Model<Target>,
            Field extends SpecialFields<Target, ForeignColumn<Entity>>,
            Symmetric extends boolean>
        (
            target: ObjectType<Target>, 
            field: Field, 
            symmetric: Symmetric
        ): Promise<Symmetric extends true ? Target : Target | null>;

    protected async hasMany<
            Target extends Model<Target>,
            Field extends SpecialFields<Target, ForeignColumn<Entity>>>
        (target: ObjectType<Target>, field: Field): Promise<Target[]>;

    // 1: M: N => 1: X
    protected hasManyToMany<
            Target extends Model<Target>,
            Route extends Model<Route>,
            TargetField extends SpecialFields<Route, ForeignColumn<Target>>,
            MyField extends SpecialFields<Route, ForeignColumn<Entity>>>
        (
            target: ObjectType<Target>,
            route: ObjectType<Route>,
            targetField: TargetField,
            myField: MyField
        ): Promise<Target[]>;

    // M: N => 1: M: 1
    protected hasManyThrough<
            Target extends Model<Target>,
            Route extends Model<Route>,
            TargetField extends SpecialFields<Target, ForeignColumn<Route>>,
            RouteField extends SpecialFields<Route, ForeignColumn<Entity>>>
        (
            target: ObjectType<Target>,
            route: ObjectType<Route>,
            targetField: TargetField,
            routeField: RouteField
        ): Promise<Target[]>;
}

Also, the safe decorator can make intializer construction much safer, too.

In the case of typeorm, using strict type checking options are discouraged. It's because the old decorator can't express the target variable's detailed options like nullable or auto-assigned default value. Therefore, in the case of typeorm, initializer constructor is not supported. Even massive insertion methods are using the dangerous Partial type, because it can't distinguish which field is whether essential or optional.

export module "typeorm"
{
    export class BaseEntity<Entity extends BaseEntity<Entity>>
    {
        // NO INITIALIZER CONSTRUCTOR EXISTS
        public constructor();

        // RECORDS ARE DANGEROUS (PARTIAL) TYPE
        // AS CANNOT DISTINGUISH WHETHER ESSENTIAL OR OPTINAL
        public static insert<Entity extends BaseEntity<Entity>>
            (
                this: CreatorType<Entity>, 
                records: Partial<Entity>[] 
            ): Promise<Entity[]>;
    }
}

However, if safe decorator implementation with ^ type operator is realized, supporting intializer constructor and massive insertion method with exact type are possible. As decorator defining each column contains the exact type information, distinguishing whether which field is essential or optional.

export class Model<Entity extends Model<Entity>>
{
    public static insert<Entity extends Model<Entity>>
        (this: CreatorType<Entity>, records: Model.Props<Entity>[]): Promise<Entity[]>;

    /**
     * Initializer Constructor
     * 
     * @param props Properties would be assigned to each columns
     */
    public constructor(props: Model.IProps<Entity>);
}

export namespace Model
{
    export type Props<Entity extends Model<Entity>>
        = OmitNever<RequiredProps<Entity, true>>
        & Partial<OmitNever<RequiredProps<Entity, false>>>;

    type RequiredProps<Entity extends Model<Entity>, Flag extends boolean> = 
    {
        [P in keyof Entity]: Entity[P] extends ColumnBase<infer Name, infer Options>
            ? IsRequired<Entity[P], Name, Options> extends Flag
                ? ColumnBase.Type<Name, Options>
                : never
            : never
    };

    type IsRequired<
            Column extends ColumnBase<Name, Options>, 
            Name extends keyof ColumnBase.TypeList, Options> =
        Column extends IncrementalColumn<any> ? false
        : Column extends UuidColumn<any> ? false
        : Options extends INullable<true> ? false
        : Options extends IDefault<any> ? false
        : true;
}

type Model.Props<BbsArticle> = 
{
    id?: number;
    bbs_group_id: number;
    parent_article_id?: number | null;
    category?: "NORMAL"|"NOTICE"|"QUESTION"|"SUGGESTION";

    writer: string;
    password: string;

    title: string;
    sub_title?: string | null;
    content: string;
    hits?: number;

    created_at?: Date;
    updated_at?: Date | null;
    deleted_at?: Date | null;
};

API Controllers

In nowadays, many JavaScript libraries like nestjs are wrapping express framework with decorator for convenient. However, wrapping features of express components with decorator, it loses chance to detecting m is-type-usage error in the compile level.

However, if safe decorator implementation with the ^ symbol is used, it also can be used safely. I'll not write the detailed description about the below code. Just look and feel what the safe decorator means:

@Controller("bbs/:group/articles")
export class BbsArticlesController
{
    @Get()
    public index
        (
            httpReq: @HttpRequest(),
            group: @Param("group", "string"),
            input: @Query<IPage.IRequest>()
        ): Promise<IPage<IArticle.ISummary>[]>;

    @Get(":id")
    public async at
        (
            httpReq: @HttpRequest(),
            group: @Param("group", "string"),
            id: @Param("id", "number")
        ): Promise<IArticle>;

    @Post()
    public async store
        (
            httpReq: @HttpRequest(), 
            group: @Param("group", "string"),
            input: @RequestBody<IArticle>()
        ): Promise<IArticle>;

    @Put(":id")
    public async update
        (
            httpReq: @HttpRequest(),
            group: @Param("group", "string"),
            id: @Param("id", "number"),
            input: @RequestBody<Partial<IArticle>>()
        ): Promise<IArticle>;
}
j-oliveras commented 4 years ago

Duplicated/related to PRs #33038 and #33290? Duplicate/similar to #4895?

samchon commented 4 years ago

@j-oliveras

Seeing PR #33038, #33290 and issue #4895 using & operator, their syntax seems like similar with the ^ type operator. However, ^ is not same with the & operator type. It's right side type is pseudo type for realizing the safe decorator implementation.

fatcerberus commented 4 years ago

I admit I don't fully understand this proposal, so I might be totally off-base here, but isn't this considered "non-ECMAScript syntax with JavaScript output" and therefore violates the current criteria for feature request?

samchon commented 4 years ago

@fatcerberus

I'd thought that there wouldn't be any problem defining decorator on the variable definition (let variable: @decorator), because type definitions are a type of virtual code that would not be compiled out to the JavaScript source code. However, listening you opinion, it can be a violation of the TypeScript's own strategy.

If defining decorator on the variable definition ((let variable: @decorator)) is violating the TypeScript's own strategy, I want to suggest another way. It's using the implicit type definition. When using a decorator function who is using the ^ type operator, the target variable's type would be defined implicitly like below:

@Table()
export class BbsArticle extends Model<BbsArticle>
{
    @Column("varchar", {
        restrict: ["NORMAL", "NOTICE", "QUESTION", "SUGGESTION"],
        default: "NORMAL"
    })
    public category!: ("NORMAL"|"NOTICE"|"QUESTION"|"SUGGESTION") ^ SomeFunction;
        // exact explicit type

    @Column("varchar")
    public title!; // implicit type -> be (string & SomeFunction)

    @Column("varchar", { nullable: true })
    public sub_title: string; // origin type is (string ^ SomeFunction), but no problem

    @Column("text")
    public content!: boolean; // mis-explicit type -> throws compile error

    @Column("int", { unsigned: true, default: 1 })
    public hits!; // implicit type -> (be number ^ SomeFunction)
}

function Column<Type extends TypeList, Options extends Options<Type>>
    (type: string, options?: Options<Type>): SomeFunction;

In similar reason, using ^ (XOR) symbol seems not validate, it's not a matter to changing the ^ symbol to another one. My goal is just to designing a safe decorator who can let TypeScript to move decorator feature from experimental to standard.

If you've an idea about the safe decorator, let's argue about it.

fatcerberus commented 4 years ago

It seems like what you want is actually not decorators (which are a runtime feature) but in fact some kind of branded type?

samchon commented 4 years ago

@fatcerberus Nope, I want the exact typed decorator for safe implementation with TypeScript compiler.

kaznovac commented 4 years ago

I like this idea.

IMHO the decorated entry should dictate type, and decorator could be checked if it adheres to this type.

It is like 'which is older chicken or the egg', but in this case decorators are weaker construct (as they convey additional information); I would expect to enforce type contract from the entry (field, class, property, ...)

samchon commented 4 years ago

@RyanCavanaugh May I edit this issue content to be more detaily?