0

Introduction

At our project we have attributes support where each attribute is class. The attribute contains info about type, optionality and name. Instead of defining an interface for each entity, I would like to automatize it. We have around 500 attributes and 100+- entities. An entity is a collector for attributes.

Example

interface AttributeClass {
  readonly attrName: string;
  readonly required?: boolean;
  readonly valueType: StringConstructor | NumberConstructor  | BooleanConstructor;
}

class AttrTest extends Attribute {
  static readonly attrName = "test";
  static readonly required = true;
  static readonly valueType = String
}

class Attr2Test extends Attribute {
  static readonly attrName = "test2";
  static readonly valueType = Number
}

interface Entity {
  test: string // AttrTest
  test2?: number // Attr2Test
}

class SomeClass {
  static attributes = [AttrTest, Attr2Test]
}

Here you can notice that, I have valueType which holds the real type. I also know the name and if is it optional. (required if the required exists and is set to true)

Concept and my not working solution

My idea is to iterate over the attributes array, map the value to the name and make it optional.

  1. Type to filter optional attribute
export type ValueOf<T> = T[keyof T];
type FilterOptionalAttribute<Attr extends AttributeClass> = ValueOf<Attr["required"]> extends false | undefined | null ? Attr : never
  1. Type to filter required attribute
type FilterRequiredAttribute<Attr extends AttributeClass> = FilterOptionalAttribute<Attr> extends never ? Attr : never
  1. Type to convert from Type to primitive type
type ExtractPrimitiveType<A> =
  A extends StringConstructor ? string :
    A extends NumberConstructor ? number :
      A extends BooleanConstructor ? boolean :
        never
  1. Type to convert from class to key-value object (required + optional)
type AttributeDataType<Attr extends AttributeClass> = { [K in Attr["attrName"]]: ExtractPrimitiveType<Attr["valueType"]> }

type OptionalAttributeDataType<Attr extends AttributeClass> = { [K in Attr["attrName"]]?: ExtractPrimitiveType<Attr["valueType"]> }
  1. Glue it together + something to infer out the array type
type UnboxAttributes<AttrList> = AttrList extends Array<infer U> ? U : AttrList;

type DataType<AttributeList extends AttributeClass[]> = OptionalAttributeDataType<FilterOptionalAttribute<UnboxAttributes<AttributeList>>> & AttributeDataType<FilterRequiredAttribute<UnboxAttributes<AttributeList>>>

What do I expect on output

class SomeClass {
  static attributes = [AttrTest, Attr2Test]
}

// notice double equals
const mapped: DataType<typeof SomeClass.attributes> == {
  test: string
  test2?: number
}

What IDE shows

using IntelliJ IDEA Ultimate

// notice double equals
const mapped: DataType<typeof SomeClass.attributes> == {
  test: string | number
  test2: number | number
}

I spend already 5 hours to solve it. Seems I'm missing something important. I would like to thanks everyone who gives me any tip what I'm doing wrong.

There are two issues:

  • Everything is required (test2 should be optional)
  • Types are mixed even when I'm inferring them

Link for TypeScript Playground

6
  • When I try to drop that code in an IDE I immediately get warnings that there's nothing called Attribute. Can you run your code through something like The Playground and edit until you get a minimal reproducible example that demonstrates the issue you have and only that issue? Commented Apr 3, 2020 at 2:51
  • Hey, I skipped the Attribute because it contains only implementation of AttributeClass. So I didn't want to make it messy and keep it short. But I will make a reproducible case for the playground. Give me few minutes. I will comment again when it is done. Thank you for tip. Commented Apr 3, 2020 at 2:54
  • You can keep it short by removing your class definitions and static properties and just give the interfaces you're trying to transform. I will likely do something similar when I write up my answer. Commented Apr 3, 2020 at 3:03
  • I added the link to the bottom of the answer. In my opinion, the class definitions are important there to understand what I'm trying to achieve. Commented Apr 3, 2020 at 3:06
  • 1
    Sorry, seems playground gave me wrong url. Looks like the previous one was loading from storage/cache. Updated the link. Commented Apr 3, 2020 at 3:11

2 Answers 2

1

I'm going to answer a stripped-down version of the question that ignores specific class definitions and the difference between constructors with static properties and instances. You can use the general technique presented below in the full version with proper transformations.

Given the following interface,

interface AttributeInterface {
  attrName: string;
  required?: boolean;
  valueType: StringConstructor | NumberConstructor | BooleanConstructor;
}

I will present a DataType<T extends AttributeInterface> that transforms T, a union of AttributeInterfaces, to the entity it represents. Note that if you have an array type Arr like [Att1, Att2] you can turn it into a union by looking up its number index signature: Arr[number] is Att1 | Att2.

Anyway, here it is:

type DataType<T extends AttributeInterface> = (
  { [K in Extract<T, { required: true }>["attrName"]]:
    ReturnType<Extract<T, { attrName: K }>["valueType"]> } &
  { [K in Exclude<T, { required: true }>["attrName"]]?:
    ReturnType<Extract<T, { attrName: K }>["valueType"]> }
) extends infer O ? { [K in keyof O]: O[K] } : never;

Before I explain it, let's try it out on the following two interfaces:

interface AttrTest extends AttributeInterface {
  attrName: "test";
  required: true;
  valueType: StringConstructor
}

interface Attr2Test extends AttributeInterface {
  attrName: "test2";
  valueType: NumberConstructor;
}

type Entity = DataType<AttrTest | Attr2Test>;
/* type Entity = {
    test: string;
    test2?: number | undefined;
} */

Looks good.


So, explanation: I take the union of attributes T and split it up into two pieces: the required attributes Extract<T, { required: true }>, and the not-required attributes Exclude<T, { required: true }>, where Extract and Exclude are utility types that filter unions.

The only difference between the processing done for those two pieces is that the mapped type for the former is required (no ? in the definition) and the mapped type for the latter is optional (with a ? in the definition), as desired... I then intersect those together.

Anyway, for every key K in the attrName property of those pieces of T, the property value is type ReturnType<Extract<T, { attrName: K }>["valueType"]>. Extract<T, {attrName: K}> just finds the one member of T with K as its attrName. Then we look up its "valueType" property, which we know is one (or more) of StringConstructor, NumberConstructor, of BooleanConstructor.

It turns out that each of these types is a callable function which returns the primitive datatype:

const s: string = String(); // StringConstructor's return type is string
const n: number = Number(); // NumberConstructor's return type is number
const b: boolean = Boolean(); // BooleanConstructor's return type is boolean

which means we can easily get the primitive type by using the ReturnType utility type.

The only thing left to explain is ... extends infer O ? { [K in keyof O]: O[K] } : never. This is a trick to take an intersection type like {foo: string} & {bar?: number} and turn it into a single object type like {foo: string; bar?: number}.


Again, it's straightforward to convert that into a form that takes an array type:

type DataTypeFromArray<T extends AttributeInterface[]> = DataType<T[number]>;

type AlsoEntity = DataTypeFromArray<[AttrTest, Attr2Test]>;
/* type AlsoEntity = {
    test: string;
    test2?: number | undefined;
} */

Which should help build the solution for the classes in your example code.


Okay, hope that helps; good luck!

Playground link to code

Sign up to request clarification or add additional context in comments.

1 Comment

Thank you very much for your explanation. I didn't know about the Array[number] access. It is really cool. That was the most important thing on my issue. Also good finding on the Return type of String\Number\Boolean. The "magic" with intersection of the two types is also really cool. Thank you for your quick answer and proper solution!!
1

I have other, but worked solution...

// Declare constructor type
type Constructor<T> = new (...args: any[]) => T;

// Declare support attribute types
type SupportTypes = [String, Number, Boolean];

// Attribyte class
class AttributeClass<K extends string, T extends SupportTypes[number], R extends boolean = false> {
  constructor(
    readonly attrName: K,
    readonly valueType: Constructor<T>,
    readonly required?: R,
  ) {
  }
}


// Declare test attributes
const AttrTest = new AttributeClass('test', String, true);
const Attr2Test = new AttributeClass('test2', Number);

const attributes = [AttrTest, Attr2Test];

// Unwrap instance of AttributeClass, to object
type UnwrapAttribute<T> = T extends AttributeClass<infer K, infer T, infer R> ? (
  R extends true ? {
    [key in K]: T;
  } : {
    [key in K]?: T;
  }
) : never;

// Transform union to intersection
// Example: UnionToIntersection<{a: string} | {b: number}> => {a: string, b: number}
type UnionToIntersection<U> = ((U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never);

// Transform tuple to intersection
// Example: TupleToIntersection<[{a: string}, {b: number}]> => {a: string, b: number}
type TupleToIntersection<U extends Array<any>> = UnionToIntersection<U[number]>;

// Map array of attributes
type MapAttributes<ArrT extends Array<AttributeClass<any, any, any>>> = TupleToIntersection<{
  [I in keyof ArrT]: UnwrapAttribute<ArrT[I]>;
}>;

// Result
const mapped: MapAttributes<typeof attributes> = {
  test: '123',
  test2: 123,
};

Playground

1 Comment

Hello, thank you for your quick answer. But you thrown away my runtime types and didn't give enough description why did you do it this way. So I managed to accept other answer. Anyway thank you for your time! I learnt something from your code. So you didn't really wasted you time. :)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.