Skip to content

Custom Type Operator #13500

Closed
Closed
@tinganho

Description

@tinganho

Overview

There are some holes in the TS type system that's really hard to cover. Since, special casing syntaxes for a specific use case is not very efficient, nonetheless a system with a lot of special cases is not a good system. Here are some current proposals, of special cases with special syntaxes in the TS issue tracker:

But, I have also had a very strong desire to strongly type an ORM(Object Relational Mapping) and strongly type data models, where I don't need to repeat the code a lot. Below, is a real world case where, I use the popular ORM, sequelize, to define a comment model:

interface Model extends Id, UpdatedAt, CreatedAt { }

interface AComment extends Model {
    text: string;
    userId?: number;
    postId?: number;
}

export interface IComment extends AComment {
    user?: IUser;
}

export const Comment = DbContext.define<IComment, AComment>('comment', {
    text: { type: Type.TEXT, allowNull: false, createdAt: false, updatedAt: true },
}); 

// In another file
const comment = Comment.create({
    text: 'helloword',
    postId: 1,
    userId: 1,
} // Must be assignable to 'AComment');

comment; // IComment

As you can see, it is quite tedious, to have to write just one model. I need to define a type for the argument side,AComment, and one for the instance side of model, IComment, and at the end I need to send in the values to the factory function to produce a SQL table for that model. What I don't understand is, we have all the types inferred from the factory function's argument. Why can't we get the type from there to produce AComment and IComment?

Proposal

I propose a solution, I call custom type operator. So in layman terms, it takes a couple of type arguments and produces a new type:

type DbArgument<F extends Fields> => {
    type Type;
    // Do something with the Type
    return Type
}

In below, we added two custom type operator DbArgument<F> and DbInstance<F>, so that we can skip to write IComment and AComment in the previous example.

namespace Type {
    export const Integer: { __integer: any };
    export const String(length: number) => {
        return {
            length,
        } as { __string?: any, length: number };
    };
}

interface Field {
    type: typeof Type.Integer | typeof Type.String(number);
    allowNull: boolean;
    createdAt: boolean;
    updatedAt: boolean;
    primaryKey: boolean;
    autoIncrement: boolean;
}

interface Fields {
    [field: string]: Field;
}

type DbInstance<F extends Fields> => {
   type Type;
   for (K in F) { // Loop through all fields of F
       type Field = F[K];
       if (Field.type == { __integer: any }) {
           Type[K]: number;
       }
       else {
           Type[K]: string;
       }
       if (Field.allowNull == true) {
           Type |= null;
       }
       if (Field.createdAt == true) {
           Type &= { createdAt: string };
       }
       if (Field.updatedAt == true) { 
           Type &= { updatedAt: string };
       }
   }
   return Type;
}

type DbArgument<F extends Fields> => {
   type Type;
   for (K in F) { // Loop through all fields of F
       type Field = F[K];
       type ValueType;
       if (Field.type == { __integer: any }) {
           ValueType = number;
       }
       else {
           ValueType = string;
       }
       if (Field.required == true) {
           Type[K]: ValueType;
       }
       else {
           Type[K?]: ValueType;
       }
   }
   return Type;
}

namespace DbContext {
    interface DbModel<F> {
        create(DbArgument<F>): Promise<DbInstance<F>>
    }

    export function define<F extends Fields>(fields: F): DbModel<F> {
    }
}

const Comment = DbContext.define('comment', {
    id: { type: Type.Integer, autoIncrement: true, primaryKey: true },
    text: { type: Type.String(50), allowNull: false, createdAt: false, updatedAt: true, required: true },
});

const comment = await Comment.create({ text: 'helloworld' } // Conform to DbArgument<F>);

comment; // DbInstance<F>  where F is { text: string }

With the value argument we produced a type for the instance side IComment and one for the argument side AComment. And all the user have to write is the argument values to the factory function define:

const Comment = DbContext.define('comment', {
    id: { type: Type.Integer, autoIncrement: true, primaryKey: true },
    text: { type: Type.String(50), allowNull: false, createdAt: false, updatedAt: true, required: true },
});

You can regard it as the equivalent to a function in the value space. But instead of the value space it operates in the type space.

Syntax

Definition

A Custom Type Operator's body need to return a type and can never reference a value.

Example

type Identity<T> = {
    return T;
} 

Grammar

TypeOperatorFunction :
    `type` `=` Identifier TypeParameters `=>` TypeOperatorFunctionBody

TypeParameters : 
    `<` TypeParameterList `>`

TypeParameterList : 
    TypeParameter
    TypeParameterList `,` TypeParameter

TypeParameter :
    Identifier
    Identifier `extends` Identifier
    `...`Identifier

TypeOperatorFunctionBody :
    `{` DeclarationsStatmentsAndExpressions `} `

Type Relations

  • A == B returns true if B is invariant to A else false.
  • A < B returns true if B is a sub type of A else false.
  • A <= B returns true if B is a sub type of A or invariant else false.
  • A > B returns true if B is a super type of A else false.
  • A >= B returns true if B is a super type of A or invariant else false.
  • A != B returns true if B is not invariant to A else false.
  • case A (switch (T)) the same as T == A.
  • T is falsy if it contains the types '', 0, false, null, undefined.
  • T is truthy when it is not falsy.

Assignment Operators

  • A &= B is equal to A = A & B;
  • A |= B is equal to A = A | B;
  • A['k']: string is equal to A = A & { k: string };
  • A['k'?]: string is equal to A = A & { k?: string };
  • A[readonly 'k']: string is equal to A = A & { readonly k: string };

If Statement

Example

if (A < B) {
}

Grammar

IfStatement :
  `if` `(` IfCondition `)` IfStatementBody

IfStatementBody : 
    `{` DeclarationsStatmentsAndExpressions `}`

Switch Statement

A case statement in a switch statement, evaluates in the same way as the binary type operator ==

Example

switch (T) {
    case '1':
        return number;
    case 'a':
    case 'b':
        return string;
    default:
        Type |= null;
}

Grammar

SwitchStatement :
  `switch` `(` Type `)` SwitchStatementBody

SwitchStatementBody : 
    `{` CaseDefaultStatements `}`

CaseDefaultStatements
    CaseStatements
    `default` `:` 

CaseStatements : 
    `case` Type `:` DeclarationsStatmentsAndExpressions
    CaseStatements `case` Type `:` DeclarationsStatmentsAndExpressions

SwitchCaseBody :
    DeclarationsStatmentsAndExpressions
    DeclarationsStatmentsAndExpressions `break`

For-In Statement

Loop through each property in a type.

Example

for (K in P) {
}

Grammar

ForInStatement :
  `for` `(` Identifier `in` Type `)` ForStatementBody

ForStatementBody :
   DeclarationStatmentsAndExpressions

For-of Statement

Loop through an array / tuple.

Example

for (S of Ss) {
}

Grammar

ForOfStatement :
  `for` `(` Identifier `of` Type `)` ForStatementBody

ForStatementBody :
   DeclarationStatmentsAndExpressions

Examples

DeepPartial

type DeepPartial<T> => {
    type Type;
    for (K in T) {
        if (T[K] == boolean || T[K] == string || T[K] == null || T[K] == number) {
            Type[K?] = T[K];
        }
        else {
            Type[K?] = DeepPartial<T[K]>;
        }
    }
    return Type;
}

Rest

type Rest<T, ...S extends string[]> => {
    type Type;
    for (KT in T) {
        type IsInS = false;
        for (KS of S) {
            if (S[KT] != undefined) {
                IsInS = true;
            }
        }
        if (!IsInS) {
            Type[KT] = T[KT]
        }
    }
    return Type;
}

cc @sandersn

Metadata

Metadata

Assignees

No one assigned

    Labels

    Out of ScopeThis idea sits outside of the TypeScript language design constraintsToo ComplexAn issue which adding support for may be too complex for the value it adds

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions