Description
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:
deepPartial
to deep optionalize a type Partial Types (Optionalized Properties for Existing Types) #4889.rest
to get the rest part of a type Rest type #13470.- etc.
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