Modify the property types on an object with TypeScript's conditional typing

Occasionally in my TypeScript work I'll need to write a function that takes in an object, changes all of the property values on that object, and then returns it. That's a pretty easy thing to do in JS/TS, all you need is to get the property names on an object with Object.getOwnPropertyNames(obj) and then iterate over them. It's also an easy case to handle in TypeScript if you know the type of object coming in and the type of object going out. You can just say "this function receives Type A and returns Type B" and move on with your life.

But making the function generic and trying to provide good types/intellisense on the returned object used to be a lot more difficult before TypeScript 2.8.

If all of the properties were going to be changed to the same type of value, you could have used a Record type. For example, if your function takes an object and changes all of its property values to a boolean, you could have easily done this:

interface MyObject { foo: string; bar: number }

type Output<T extends Object> = Record<keyof T, boolean>

const a: Output<MyObject> = ... // { foo: boolean; bar: boolean; }

The types became problematic once you wanted to change the property type based on its original value. Let's say you want to change any string property on the object to a number, but any other property type should become a boolean. You would have had to do something like this:

interface MyObject { foo: string; bar: number }

type Output<T extends Object> = Record<keyof T, boolean | number>;

const a: output<MyObject> = ... // { foo: boolean | number; bar: boolean | number; }

Suddenly every single property on the object has the type of all possible properties on the object. So if your input object has a string property and a boolean property, now every property on that object has the type boolean | number and you need to check if the value is a boolean or a number before you can even attempt to use it.

Thankfully our control of the types in generic situations has become much more fine-grained and nuanced after TypeScript 2.8. With the introduction of conditional types, suddenly we can say "if this type is A, turn it into B. If this type is C, turn it into D", and so on. For example, let's say you want to create a generic type that accepts a string or a boolean and turns it into the opposite:

type Opposites<T extends string | boolean> = T extends string ? boolean : string;

Pretty easy, and it looks largely like the ternary operators we already have in JavaScript. Take this type, give it a string, receive a boolean; give it a boolean, receive a string. Simple! However, it's a little bit more cimplicated if you want to look at the properties on an object and moprh them based on their value type.

Let's say you have two different classes: StringThing and NumberThing. You've also got an interface that looks like { foo: string; bar: number } and you want to write a generic function that will morph that interface, requiring the string values to be StringThing and number values to be NumberThing. Here's what that looks like in code:

class StringThing {}

class BooleanThing {}

interface MyObject { foo: string; bar: number; }

// TODO: morph T to make its string properties a StringThing and number properties a NumberThing
type Thingified<T extends object> = ... 

function myFunc<T>(input: Thingified<T>): void { ... }

So, how do we write that Thingified<T> type, i.e. how do we morph MyObject to make its foo property a StringThing and its bar property a NumberThing? You might try taking advantage of TypeScript's built-in Record<K, T> type, which constructs an interface where the keys (K) all have the value of T:

type Thingified<T extends object> = Record<keyof T, T[keyof T]>
const a: Thingified<MyObject> = ... // { foo: StringThing | NumberThing; bar: StringThing | NumberThing }

That doesn't quite work, though. Instead of changing foo to StringThing and bar to NumberThing, it just changed them both to StringThing | NumberThing, i.e. a union type of all possible values. You might think that's because we didn't use conditional types, though, so let's give it another try:

type Thingified<T extends object> = Record<keyof T, T[keyof T] extends string ? StringThing : NumberThing>
const a: Thingified<MyObject> = ... // { foo: StringThing; bar: StringThing }

That too doesn't quite work. Instead of being a union type of all possible values, it's now just defaulting every single value to StringThing.

The real solution here is not to use the Record type at all, but rather use an object type:

type Thingified<T extends object> = { [K in keyof T]: T[K] extends string ? StringThing : NumberThing }
const a: Thingified<MyObject> = ... // { foo: StringThing; bar: NumberThing }

And now it's working! The Thingified<T> type is accepting a generic object type, looking at its properties, and morphing the strings to StringThing. All other types that are not strings get turned into NumberThing.

Note that you can make your conditional types as long as you want, just like a ternary operator:

type Thingified<T extends object> = {
    [K in keyof T]: T[K] extends string ? StringThing
        : T[K] extends number ? NumberThing
        : T[K] extends boolean ? BooleanThing
        : T[K] extends function ? FunctionThing
        : DefaultThing

Learn how to build rock solid Shopify apps with C# and ASP.NET!

Did you enjoy this article? I wrote a premium course for C# and ASP.NET developers, and it's all about building rock-solid Shopify apps from day one.

Enter your email here and I'll send you a free sample from The Shopify Development Handbook. It'll help you get started with integrating your users' Shopify stores and charging them with the Shopify billing API.

We won't send you spam. Unsubscribe at any time.