Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Combine two types into an interface elegantly in Typescript

My goal

I have a string enum E and an interface I with identical sets of keys. I want to construct a new mapped type. For each shared key k, it should use the enum value E.k as a property name. The type of member I.k should be this new property's type.

Some background / motivation for my use case

I get objects from a REST API. I cannot change their structure. The objects' key names are very unreadable and ugly because of legacy reasons (I simulate this in FooNames in the example). This makes development painful and unnecessarily increases errors both in code, but more critically in understanding, when working with these objects and manipulating them.

We have hidden these names using a clean interface of our own (simulated by via "first" | "second" | "third"). However, when writing back objects to the backend, they need to have the "ugly" structure again. There are several dozen object types (with different sets of fields each), which is what makes it so painful to work with confusing field names.

We are attempting to minimize redundancy - while still having static type and structure checking via TS compiler. Thus, a mapped type that triggers typechecks based on the existing abstractions would be very helpful.

Code example

Can the BackendObject type below be somehow realized as a mapped type in Typescript? I have thus far failed to find a way. See this playground for all the code in this question.

// Two simple abstractions per object type, e.g. for a type Foo....
enum FooNames {
  first = 'FIRST_FIELD',
  second = 'TT_FIELD_SECOND',
  third = 'third_field_33'
}
interface FooTypes {
  first: string,
  second: number,
  third: boolean
}
// ... allow for generic well-formed objects with structure and typechecks:
interface FrontendObject<FieldNames extends keyof FieldTypes, FieldTypes> {
  fields: {[K in FieldNames]: FieldTypes[K]}
}

// Example object in the case of our imaginary type "Foo":
let checkedFooObject: FrontendObject<keyof typeof FooNames,FooTypes> = {
  fields: {  
    first: '',   // typechecks everywhere!
    second: 5,
    third: false,
//  extraProp: 'this is also checked and disallowed'
  }
}

// PROBLEM: The following structure is required to write objects back into database
interface FooBackendObject { 
  fields: {
    FIRST_FIELD: string,
    TT_FIELD_SECOND_TT: number,
    third_field_33: boolean
    // ...
    // Adding new fields manually is cumbersome and error-prone;
    // critical: no static structure or type checks available
  }
}
// IDEAL GOAL: Realize this as generic mapped type using the abstractions above like:
let FooObjectForBackend: BackendObject<FooNames,FooTypes> = {
  // build the ugly object, but supported by type and structure checks
};

My attempts thus far

1. Enum (Names) + Interface (types)

interface BackendObject1<FieldNames extends string, FieldTypes> {
  fields: {
    // FieldTypes cannot be indexed by F, which is now the ugly field name
    [F in FieldNames]: FieldTypes[F]; 
    // Syntax doesn't work; no reverse mapping in string-valued enum
    [F in FieldNames]: FieldTypes[FieldNames.F]; 
  }
}
// FAILURE Intended usage:
type FooObjectForBackend1 = BackendObject1<FooNames,FooTypes>;

2. Use ugly keys for field type abstraction instead

interface FooTypes2 {
  [FooNames.first]: string,
  [FooNames.second]: number,
  [FooNames.third]: boolean,
}

// SUCCESS Generic backend object type
interface BackendObject2<FieldNames extends keyof FieldTypes, FieldTypes> {
  fields: {
    [k in FieldNames]: FieldTypes[k]
  }
}
// ... for our example type Foo:
type FooBackend = BackendObject2<FooNames, FooTypes2>
let someFooBackendObject: FooBackend = {
  fields: {
    [FooNames.first]: 'something',
    [FooNames.second]: 5,
    [FooNames.third]: true
  }
}

// HOWEVER....  Generic frontend object FAILURE
interface FrontendObject2<NiceFieldNames extends string, FieldNames extends keyof FieldTypes, FieldTypes> {
  fields: {
    // Invalid syntax; no way to access enum and no matching of k
    [k in NiceFieldNames]: FieldTypes[FieldNames.k]
  }
}

3. Combine object abstraction as tuples, using string literal types

// Field names and types in one interface:
interface FooTuples {
  first: ['FIRST_FIELD', string]
  second: ['TT_FIELD_SECOND', number]
  third: ['third_field_33', boolean]
}


// FAILURE
interface BackendObject3<TypeTuples> {
  fields: {
    // e.g. { first: string }
    // Invalid syntax for indexing
    [k in TypeTuples[1] ]: string|number|boolean
  }
}

4. One "fields" object per type

// Abstractions for field names and types combined into a single object
interface FieldsObject {
  fields: {
    [niceName: string]: {
      dbName: string,
      prototype: string|boolean|number // used only for indicating type
    }
  }
}
let FooFields: FieldsObject = {
  fields: {
    first: {
      dbName: 'FIRST_FIELD',
      prototype: ''      
    },
    second: {
      dbName: 'TT_FIELD_SECOND',
      prototype: 0
    },
    third: {
      dbName: 'third_field3',
      prototype: true,
    }
  }
}

// FAIL: Frontend object type definition 
interface FrontendObject3<FieldsObject extends string> {
  fields: {
    // Cannot access nested type of 'prototype'
    [k in keyof FieldsObject]: FieldsObject[k][prototype];  
  }
}
// FAIL: Backendobject type definition
interface BackendObject3<FieldsObject extends string> {
  fields: {
    [k in keyof ...]:  // No string literal type for all values of 'dbName'
  }
}

like image 695
MaxAxeHax Avatar asked Mar 17 '19 15:03

MaxAxeHax


People also ask

How do I combine two TypeScript interfaces?

To merge two interfaces with TypeScript, we can use extends to extend multiple interfaces. to create the IFooBar that extends IFoo and IBar . This means IFooBar has all the members from both interfaces inside.

Is it possible to combine types in TS?

TypeScript allows merging between multiple types such as interface with interface , enum with enum , namespace with namespace , etc.

How do I assign two TypeScript types?

TypeScript allows you to define multiple types. The terminology for this is union types and it allows you to define a variable as a string, or a number, or an array, or an object, etc. We can create union types by using the pipe symbol ( | ) between each type.


1 Answers

I think the following should work for you:

type BackendObject<
  E extends Record<keyof E, keyof any>,
  I extends Record<keyof E, any>
  > = {
    fields: {
      [P in E[keyof E]]: I[{
        [Q in keyof E]: E[Q] extends P ? Q : never
      }[keyof E]]
    }
  }

interface FooBackendObject extends
  BackendObject<typeof FooNames, FooTypes> { }

The type BackendObject<E, I> is not an interface, but you can declare an interface for any particular concrete values of E and I as in FooBackendObject above. So, in BackendObject<E, I>, we expect E to be the mapping to keys (represented in FooBackendObject by the FooNames value, whose type is typeof FooNames... you can't just use the FooNames type here, since that doesn't contain the mapping.), and I to be the mapping to values (represented in the FooBackendObject by the interface FooTypes).

The mapped/conditional types being used might be a bit ugly, but this is what we're doing: first, the keys of the fields object come from the values of E (E[keyof E]). For each key P in that, we find the key of E which corresponds to it ({[Q in keyof E]: E[Q] extends P ? Q : never}[keyof E]), and then use that key to index into I for the value type.

Let's explain {[Q in keyof E]: E[Q] extends P ? Q : never}[keyof E] more fully. Generally a type like {[Q in keyof E]: SomeType<Q>}[keyof E] will be the union of SomeType<Q> for all Q in keyof E. You can cash it out with a concrete type if that makes more sense... if E is {a: string, b: number}, then {[Q in keyof E]: SomeType<Q>} will be {a: SomeType<'a'>, b: SomeType<'b'>}, and then we look up its values at keys keyof E, which is {a: SomeType<'a'>, b: SomeType<'b'>}['a'|'b'], which becomes SomeType<'a'> | SomeType<'b'>. In our case, SomeType<Q> is E[Q] extends P ? Q : never, which evaluates to Q if E[Q] matches P, and never otherwise. Thus we are getting the union of Q values in keyof E for which E[Q] matches P. There should be just one of those (if the enum doesn't have two keys with the same value).

It might be useful for you to go through the exercise of manually evaluating BackendObject<typeof FooNames, FooTypes> to see it happen.

You can verify that it behaves as desired. Hope that helps. Good luck!

like image 77
jcalz Avatar answered Nov 15 '22 08:11

jcalz