Custom Type Operator
tinganho opened this issue ยท 10 comments
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
returnstrue
ifB
is invariant toA
elsefalse
.A < B
returnstrue
ifB
is a sub type ofA
elsefalse
.A <= B
returnstrue
ifB
is a sub type ofA
or invariant elsefalse
.A > B
returnstrue
ifB
is a super type ofA
elsefalse
.A >= B
returnstrue
ifB
is a super type ofA
or invariant elsefalse
.A != B
returnstrue
ifB
is not invariant toA
elsefalse
.case A
(switch (T)) the same asT == A
.T
isfalsy
if it contains the types''
,0
,false
,null
,undefined
.T
istruthy
when it is notfalsy
.
Assignment Operators
A &= B
is equal toA = A & B
;A |= B
is equal toA = A | B
;A['k']: string
is equal toA = A & { k: string }
;A['k'?]: string
is equal toA = A & { k?: string }
;A[readonly 'k']: string
is equal toA = 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
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.
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?
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.
type P<T extends WithKey> = {
// all properties are optional
[P in keyof T]?: T[P];
} & {
// but keep "key" property as mandatory.
key: string;
};
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.
@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>
!
@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
.
@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:
- mapped types: already possible, but gets better with function application (6606)
- conditionals (
if
): string/number-based conditionals are possible now (#14833) - pattern-match (
switch
) - type checks
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.
Automatically closing this issue for housekeeping purposes. The issue labels indicate that it is unactionable at the moment or has already been addressed.