Home > database >  Recursive type definition with mapped and conditional types
Recursive type definition with mapped and conditional types

Time:10-02

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.

Playground link to code

  • Related