I'm trying to come up with a way to have better type safety with TypeORM. The following are some example TypeORM entity definitions.
import { BaseEntity, Entity, Column, ManyToMany, JoinTable, ManyToOne, OneToMany } from 'typeorm';
@Entity()
class Product extends BaseEntity {
@Column({ type: 'text' })
public name: string;
@Column({ type: 'text' })
public description: string;
@ManyToMany(_ => Category, category => category.products)
@JoinTable()
public categories: Category[];
}
@Entity()
class Category extends BaseEntity {
@Column({ type: 'text' })
public name: string;
@ManyToMany(_ => Product, product => product.categories)
public products: Product[];
@ManyToOne(_ => Supplier, supplier => supplier.categories, { nullable: false })
public supplier: Supplier;
}
@Entity()
class Supplier extends BaseEntity {
@Column('text')
public name: string;
@Column({ type: 'boolean', default: true })
public isActive: boolean;
@OneToMany(_ => Category, category => category.supplier)
public categories: Category[];
}
I'm trying to define a type which will be valid only for properties of an entity which are entities themselves. This is best explained with an example:
type Relations<T extends BaseEntity> = {
// An object whose:
// - Keys are some (or all) of the keys in type T, whose type is something which extends BaseEntity.
// - Values are another Relations object for that key.
}
// Some examples
// Type error: "color" is not a property of Product.
const a: Relations<Product> = {
color: {}
}
// Type error: "name" property of Product is not something that extends "BaseEntity".
const a: Relations<Product> = {
name: {}
}
// OK
const a: Relations<Product> = {
categories: {}
}
// Type error: number is not assignable to Relations<Category>
const a: Relations<Product> = {
categories: 42
}
// Type error: "description" is not a property of Category.
const a: Relations<Product> = {
categories: {
description: {}
}
}
// Type error: "name" property of Category is not something that extends "BaseEntity".
const a: Relations<Product> = {
categories: {
name: {}
}
}
// OK
const a: Relations<Product> = {
categories: {
supplier: {}
}
}
// Type error: Date is not assignable to Relations<Supplier>
const a: Relations<Product> = {
categories: {
supplier: new Date()
}
}
// etc.
I came up with the following so far but it doesn't work and probably not even close to the right answer:
type Flatten<T> = T extends Array<infer I> ? I : T;
type ExcludeNonEntity<T> = T extends BaseEntity | Array<BaseEntity> ? Flatten<T> : never;
type Relations<T extends BaseEntity> = {
[P in keyof T as ExcludeNonEntity<P>]: Relations<T[P]>;
};
CodePudding user response:
My suggestion would be something like this:
type DrillDownToEntity<T> = T extends BaseEntity ?
T : T extends ReadonlyArray<infer U> ? DrillDownToEntity<U> : never;
type Relations<T extends BaseEntity> =
{ [K in keyof T]?: Relations<DrillDownToEntity<T[K]>> }
The DrillDownToEntity<T>
is something like your Flatten<T>
type mixed with ExcludeNonEntity<T>
, except that it acts recursively. It extracting all array element types for any arbitrary amount of nesting, keeping only those types assignable to BaseEntity
. Observe:
type DrillTest = DrillDownToEntity<Category | string | Product[] | Supplier[][][][][]>
// type DrillTest = Category | Product | Supplier
I don't know if you are ever going to have arrays-of-arrays; if you aren't, you can make this non-recursive. Importantly, though, any type not ultimately assignable to BaseEntity
is discarded.
Then Relations<T>
is a type with all optional properties, whose keys are from T
, and whose values are Relations<DrillDownToEntity<>>
of the properties of T
. Generally speaking, most properties will be of type never
, since most properties are not themselves assignable to BaseEntity
. Observe:
type RelationsProduct = Relations<Product>;
/* type RelationsProduct = {
name?: undefined;
description?: undefined;
categories?: Relations<Category> | undefined;
hasId?: undefined;
save?: undefined;
remove?: undefined;
softRemove?: undefined;
recover?: undefined;
reload?: undefined;
} */
Note that an optional property of type never
and one of type undefined
are the same, at least without the --exactOptionalPropertyTypes
compiler flag enabled. This has the effect of preventing you from assigning any property of these types at all unless they are undefined
. I find this is probably better than merely omitting those properties; a value of type {categories?: Relations<Category>}
might or might not have a string
-valued name
property, according to structural typing, while one of the form {categories?: Relations<Category>, name?: never}
will definitely not have a defined name
property at all.
You can verify that your example code works as desired with this definition of Relations
.
The following code:
type Relations<T extends BaseEntity> = {
[P in keyof T as ExcludeNonEntity<P>]: Relations<T[P]>;
};
does not work for several reasons, the most immediate of which is that you are using key remapping syntax to presumably suppress non-BaseEntity
-assignable properties, but you are writing ExcludeNonEntity<P>
where P
is a key type. And no keys are going to be BaseEntity
, so that is very likely to end up excluding all keys, even if you could get it to work. If you want to suppress keys, then you would need to check T[P]
and not P
, and then omit or include P
based on that. There are other minor issues (e.g., properties are not optional) but the big one is treating keys as if they were values.