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

Custom Type Operator #13500

Closed
tinganho opened this issue Jan 15, 2017 · 10 comments
Closed

Custom Type Operator #13500

tinganho opened this issue Jan 15, 2017 · 10 comments
Labels
Out of Scope This idea sits outside of the TypeScript language design constraints Too Complex An issue which adding support for may be too complex for the value it adds

Comments

@tinganho
Copy link
Contributor

tinganho commented Jan 15, 2017

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

@tinganho tinganho mentioned this issue Jan 15, 2017
@mhegazy
Copy link
Contributor

mhegazy commented Jan 16, 2017

Type operators are used to generate types, but that is not the only place where understanding them is needed. for instance when doing an assignablity checks between two type operators the system needs to understand what they mean, for example to check if A is assignable to T | U , the system needs to check that A is assignable to T or U. Now, if knowing what this type means require running some code, then this does not work any more.

The type system also needs to know what types mean when it is doing inferences. Inferences are really the inverse of creating the type. again if creating the type involves running code, the system can not infer to them.

Doing this really needs extensbility to the compiler itself, allowing adding code in all places where knowledge about types is needed, e.g. assignablity checking, inference, as well as resolution.

@mhegazy mhegazy added Too Complex An issue which adding support for may be too complex for the value it adds Out of Scope This idea sits outside of the TypeScript language design constraints labels Jan 16, 2017
@tinganho
Copy link
Contributor Author

tinganho commented Jan 16, 2017

I'm just trying to figure out how it cannot work. Could you give me an example using the proposed custom type operator?

For instance with Partial defined with a custom type operator:

type Partial<T> => {
    type Type;
    for (K in T) {
        Type[K?] = T[K];
    }
    return Type;
}

interface State {
    a: number;
    b: number;
}

function setState(state: Partial<State>) {
}

What's the difference, compare too mapped types? Why does mapped types works but not the other?

type Partial<T> = {
    [P in keyof T]?: T[P];
};

interface State {
    a: number;
    b: number;
}

function setState(state: Partial<State>) {
}

Isn't it nearly the same code being run on the compiler, in both example? Even though one of the code needs to be interpreted, the same logic is being run?

@Knagis
Copy link
Contributor

Knagis commented Feb 9, 2017

I had a similar wish - for example I would like to have a partial type where some specific properties are still required:

interface WithKey { key: string; }
type P<T extends WithKey> = {
    // all properties are optional
    [P in keyof T]?: T[P];

    // but keep "key" property as mandatory.
    key: string;
};

I had other cases as well, for example, add two properties for each in the source ({foo: number} -> {fooNew: number; fooOld: number;}), convert the types ({foo: Enum} -> {foo: Enum | keyof typeof Enum}) etc.

I previously needed complex code generators for what can now be solved with Partial and Readonly, adding this ability would eliminate any need to generate source code in order to provide complex definitions for users of our library.

@mhegazy
Copy link
Contributor

mhegazy commented Feb 9, 2017

type P<T extends WithKey> = {
    // all properties are optional
    [P in keyof T]?: T[P];
} & {
    // but keep "key" property as mandatory.
    key: string;
};

@Knagis
Copy link
Contributor

Knagis commented Feb 10, 2017

@mhegazy - yes, that particular issue can be solved, I mistakenly wrote it down because of a different issue #13345 that prevents using this construct with JSX.IntrinsicElements.

@patsissons
Copy link

can we solve DeepPartial<T> with this type def?

type DeepPartial<T> = {
    [P in keyof T]?: DeepPartial<T[P]>;
};

this appears to work from light testing, but I don't have a practical use case to really make sure it makes sense.

@aaronbeall
Copy link

aaronbeall commented Mar 17, 2017

@patsissons Your solution worked for my real use case. I think it would make a good addition to the core lib type definitions alongside Partial<T>!

@jmlopez-rod
Copy link

@patsissons I arrived at your solution but it is only now that I see some issues. Consider the following:

interface IName {
    name: string;
}

interface IType extends IName {
    typeName: IName;
    color: string;
}

interface IRoom extends IName {
    roomType: IType;
}

interface IUser extends IName {
    room: IRoom;
}

type DeepPartial<T> = {[P in keyof T]?: DeepPartial<T[P]>; };

const p0: Partial<IUser> = { room: 0 }; // TS complains, room should be IRoom | undefined
const p1: DeepPartial<IUser> = { room: 0 }; // ? Shouldn't room be of type DeepPartial<IRoom> | undefined
const p2: DeepPartial<IUser> = { room: {} }; // OK, we have a DeepPartial<IRoom>
const p3: DeepPartial<IUser> = { room: { a: 0 } }; // TS complains, a doesn't exists in DeepPartial<IRoom> | undefined
const p4: DeepPartial<IUser> = { room: { roomType: 0 } };
const p5: DeepPartial<IUser> = { room: { roomType: { } } };
const p6: DeepPartial<IUser> = { room: { roomType: { a: 0 } } };
const p7: DeepPartial<IUser> = { room: { roomType: { typeName: 0 } } };
const p8: DeepPartial<IUser> = { room: { roomType: { typeName: {} } } };
const p9: DeepPartial<IUser> = { room: { roomType: { typeName: { a: 0 } } } };
const p10: DeepPartial<IUser> = { room: { roomType: { typeName: { name: 0 } } } };

I have written some examples which you can run in the typescript playground. Some examples are good but some others are questionable. For instance, do you happen to know why the definition for p1 is ok? I was expecting TS to tell me that I cannot assign number to DeepPartial<IRoom> | undefined.

@KiaraGrouwstra
Copy link
Contributor

@patsissons: sounds like DeepReadonly discussed at #12424.

@tinganho: if you'd be okay with a more functional version of your code (i.e. no mutating, incl. *= operators), I think you'll find only a few pieces of the puzzle are still missing. Specifically, your top-level notation would change as follows:

type DbArgument<F extends Fields> => {
    type Type = {};
    // ...
    return ChangedType;
}
type DbArgument<
  F extends Fields,
  Types extends {},
  // ^ note you can make declarations here, which may include processing!
  // ...
> = ChangedType;

Other functionality you used:

Once #6606 lands, these are all good (details).

Iteration (where not covered by mapped types):

  • array iteration (For-In): needs either tuple destructuring or type-level arithmetic (+) (notes).
  • object iteration (For-of): could be achieved through array iteration if we could obtain a tuple of the object keys. This would become viable if we had a type-level (a) union to tuple conversion, (b) Object.keys, or (c) Object.values. I'm not aware of proposals in this direction.

@mhegazy
Copy link
Contributor

mhegazy commented Sep 11, 2017

Automatically closing this issue for housekeeping purposes. The issue labels indicate that it is unactionable at the moment or has already been addressed.

@mhegazy mhegazy closed this as completed Sep 11, 2017
@microsoft microsoft locked and limited conversation to collaborators Jun 19, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Out of Scope This idea sits outside of the TypeScript language design constraints Too Complex An issue which adding support for may be too complex for the value it adds
Projects
None yet
Development

No branches or pull requests

7 participants