Changing Property Name in Typescript Mapped Type

Key remapping in Mapped Types was introduced in typescript 4.1:

type SomeData = {
    prop1: string;
    prop2: number;
}

type WithChange<T> = { [P in keyof T & string as `${P}Change`]: T[P] };

type SomeMoreData = WithChange<SomeData>; // {  prop1Change: string, prop2Change: number }

Playground


In above example we use two new features: as clause in mapped types + template literal type.

Template string types are the type space equivalent of template string expressions. Similar to template string expressions, template string types are enclosed in backtick delimiters and can contain placeholders of the form ${T}, where T is a type that is assignable to string, number, boolean, or bigint. Template string types provide the ability to concatenate literal strings, convert literals of non-string primitive types to their string representation, and change the capitalization or casing of string literals. Furthermore, through type inference, template string types provide a simple form of string pattern matching and decomposition.

Some examples:

type EventName<T extends string> = `${T}Changed`;
type Concat<S1 extends string, S2 extends string> = `${S1}${S2}`;
type T0 = EventName<'foo'>;  // 'fooChanged'
type T1 = EventName<'foo' | 'bar' | 'baz'>;  // 'fooChanged' | 'barChanged' | 'bazChanged'
type T2 = Concat<'Hello', 'World'>;  // 'HelloWorld'
type T3 = `${'top' | 'bottom'}-${'left' | 'right'}`;  // 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'

Additionally mapped types support an optional as clause through which a transformation of the generated property names can be specified:

{ [P in K as N]: X }

where N must be a type that is assignable to string.


Pre-TS4.1 answer;

You can't do it automatically. The big blocker is that there is currently no type operator that lets you append string literals at the type level, so you can't even describe the transformation you're doing:

// without Append<A extends string, B extends string>, you can't type this:

function appendChange<T extends string>(originalKey: T): Append<T,'Change'> {
  return originalKey+'Change';
}

There is a suggestion for this feature, but who knows if it will happen.

That means if you want to transform keys, you need to actually hard-code the specific mapping you're looking for, from string literal to string literal:

type SomeMoreDataMapping = {
  prop1: "prop1Change"
  prop2: "prop2Change"
}

Armed with this mapping, you can define these:

type ValueOf<T> = T[keyof T]
type KeyValueTupleToObject<T extends [keyof any, any]> = {
  [K in T[0]]: Extract<T, [K, any]>[1]
}
type MapKeys<T, M extends Record<string, string>> =
  KeyValueTupleToObject<ValueOf<{
    [K in keyof T]: [K extends keyof M ? M[K] : K, T[K]]
  }>>

Brief runthrough:

  • ValueOf<T> just returns the union of property value types for type T.
  • KeyValueTupleToObject takes a union of tuple types like this: ["a",string] | ["b",number] and turns them into an object type like this: {a: string, b: number}.
  • And MapKeys<T, M> takes a type T and a key-mapping M and substitutes any key in T which is present in M with the corresponding key from M. If a key in T is not present in M, the key is not transformed. If a key in M is not present in T, it will be ignored.

Now you can (finally) do this:

type SomeMoreData= MapKeys<SomeData, SomeMoreDataMapping>;

And if you inspect SomeMoreData, you see it has the right type:

var someMoreData: SomeMoreData = {
  prop1Change: 'Mystery Science Theater',
  prop2Change: 3000
} // type checks

This should allow you to do some fun things like:

function makeTheChange<T>(input: T): MapKeys<T, SomeMoreDataMapping> {
  var ret = {} as MapKeys<T, SomeMoreDataMapping>;
  for (var k in input) {
    // lots of any needed here; hard to convince the type system you're doing the right thing
    var nk: keyof typeof ret = <any>((k === 'prop1') ? 'prop1Change' : (k === 'prop2') ? 'prop2Change' : k);
    ret[nk] = <any>input[k];    
  }
  return ret;
}

var changed = makeTheChange({ prop1: 'Gypsy', prop2: 'Tom', prop3: 'Crow' });
console.log(changed.prop1Change.charAt(0)); //ok
console.log(changed.prop2Change.charAt(0)); //ok
console.log(changed.prop3.charAt(0)); //ok

Hope that helps. Good luck!

UPDATED SEP 2018 to use advantage of conditional types introduced with TS 2.8


Another update: if you want this to work with optional properties it gets more complicated:

type RequiredKeys<T> = { [K in keyof T]-?: {} extends Pick<T, K> ? never : K }[keyof T];
type OptionalKeys<T> = { [K in keyof T]-?: {} extends Pick<T, K> ? K : never }[keyof T];
type MapKeys<T, M extends Record<string, string>> =
  KeyValueTupleToObject<ValueOf<{
    [K in RequiredKeys<T>]-?: [K extends keyof M ? M[K] : K, T[K]]
  }>> & Partial<KeyValueTupleToObject<ValueOf<{
    [K in OptionalKeys<T>]-?: [K extends keyof M ? M[K] : K, T[K]]
  }>>> extends infer O ? { [K in keyof O]: O[K] } : never;

If you need to support index signatures it would get even more complicated.

Playground link to code

Tags:

Typescript