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'
}
}
See Question&Answers more detail:
os