DEV Community

Avinash
Avinash

Posted on

Keep Store type sync with definition

Introduction

I wanted to drive the types from the store definition itself. To achieve this, I learned about TypeScript utilities and how to create custom ones directly from the store definition itself.

Before diving directly into the final utility, we go through some of the functionality that is provided by the typescript that is used in the final utility created.

To view the code jump to the code section

Const assertions

Const assertion tells typescript that to type will not change its length and content. So what it means is suppose the type is

const arr1 = ['a','b'];

//type is 
// string[]

const arr = ['a','b'] as const;

// type is
// readonly ["a", "b"]
Enter fullscreen mode Exit fullscreen mode

Conditional Types

The syntax of the conditional types is similar to the ternary operator in JavaScript. Typescript uses extends to check the type and based on the check it either returns a truthy type or a falsy type.

type A<Type> = Type extends string ? Type: never;

const a: A<'A simple imutable string'>  = 'A simple imutable string'; // type of a: 'A simple imutable string'

const str: A<1> = 1 // error: Type 'number' is not assignable to type 'never'
Enter fullscreen mode Exit fullscreen mode

Infer keyword

In typescript, we can use Infer to gather the type of type using the infer keyword.

type A<Type> = Type extends Array<infer Item> ? Item : never;

const A = ['s','a'];
const D = [1,2,3,4,5];

type B = A<typeof A> // string
type C = A<typeof D> // number
Enter fullscreen mode Exit fullscreen mode

This is useful when it comes to inferring the type of items present in the array. One thing to note about infer is that infer should only be used in conditional types.

Infer can also be used to check the return type of function. For example,

type A <Func> = Func extends (...args: any) => infer Return? Return: never

const funcToReturnString = () => 's'

type B = A<typeof funcToReturnString> // type of B is string
Enter fullscreen mode Exit fullscreen mode

Mapped Type

Typescript provides a way to get the type of keys in the object. We can reduce the redundant steps of mapping all the properties in object using Mapped types

type A<Type> = {
    [property in keyof Type]: boolean
}
Enter fullscreen mode Exit fullscreen mode

The above type will map all properties in objects to the boolean type.
In typescript the modifiers ( are responsible for changing the mutability or optionality) of object property using Readonly of? operator in object property.

type ImmutableObj = {
    readonly name: string;
    readonly uuid: string;
}

type OptionalType = {
    email?: string;
    address?: string;
}
Enter fullscreen mode Exit fullscreen mode

We can change the optionality or immutability of the object properties by adding or removing the modifiers. Let us look into the example.

type AllOptional<Type> = {
    [property in keyof Type]+?: Type[property]
}

const RequiredObj = {
    name:'Tony',
    phone: 'X-XXX-XXX'
}

type UserObjType = AllOptional<typeof RequiredObj>
Enter fullscreen mode Exit fullscreen mode

There is more to mapped types in typescript documentation.

Recursive types

Recursion is the most common concept in many programming languages including javascript. At its core recursive function means calling the same function until it reaches the exist state at which point the recursive function exists.

In the context of the typescript, recursion means. Referencing the same type from its definition.

Understanding by looking into an example. If we look into typescript Readonly utility

type Readonly<T> = {
readonly [P in keyof T]: T[P]
}
Enter fullscreen mode Exit fullscreen mode

The problem with the above utility is it doen’t provides readonly type for nested type types such as.

const deep = {
    a:{
        b:{
            c:['sdf', {
                d:'str'
            }]
        }
    }
}

type Deep  = Readonly<typeof deep> 

// type Deep = {
//    readonly a: {
//        b: {
//            c: (string | {
//                d: string;
//            })[];
//        };
//    };
//}
Enter fullscreen mode Exit fullscreen mode

Mixing it

The goal is to drive the type definition from the initial state definition. Suppose we define the default state object something like this

const defaultState = {
  isConnected: false,
  currRoom: {
    roomName: "",
    currUser: {},
    allUsers: [
      {
        name: "",
        userId: "",
      },
    ],
  },
  gameState: {
    owner: {},
    remainingLetters: [""],
    isChoosing: false,
    guessWord: "",
    myTurn: false,
    selectedLetters: [""],
    correctSelectedLetters: [""],
    wrongSelectedLetters: [""],
    turn: {
      name: "",
    },
    isCorrect: false,
    incorrect: 0,
    gameOver: false,
  },
}
Enter fullscreen mode Exit fullscreen mode

I don’t want to create a type or interface for this object as when I update the default state I’ll have to update the definition as well. How to achieve DRY for this type of definition.

The above object is the default state from which I want to drive the types. Let us go through all the approaches.

Using const assertion

We can simply use const assertion and typeof to get the types

const defaultState = {
  isConnected: false,
  currRoom: {
    roomName: "",
    currUser: {},
    allUsers: [
      {
        name: "",
        userId: "",
      },
    ],
  },
  gameState: {
    owner: {},
    remainingLetters: [""],
    isChoosing: false,
    guessWord: "",
    myTurn: false,
    selectedLetters: [""],
    correctSelectedLetters: [""],
    wrongSelectedLetters: [""],
    turn: {
      name: "",
    },
    isCorrect: false,
    incorrect: 0,
    gameOver: false,
  },
} as const

type Store = typeof defaultState 

//type will be 

//type Store = {
//    **readonly isConnected: false;** 
//    readonly currRoom: {
//        readonly roomName: "";
//        readonly currUser: {};
//        readonly allUsers: readonly [{
//            **readonly name: "";**
//            readonly userId: "";
//        }];
//    }; ....
Enter fullscreen mode Exit fullscreen mode

we can see that the type of isConnected is false but we wanted TS to infer it as boolean. Another limitation is readonly name: "" is typed as an empty string rather than type string.

Using Readonly utility

We can also use the Readonly utility type provided by Typescript.

 **const defaultState = {
  isConnected: false,
  currRoom: {
    roomName: "",
    currUser: {},
    allUsers: [
      {
        name: "",
        userId: "",
      },
    ],
  },
  gameState: {
    owner: {},
    remainingLetters: [""],
    isChoosing: false,
    guessWord: "",
    myTurn: false,
    selectedLetters: [""],
    correctSelectedLetters: [""],
    wrongSelectedLetters: [""],
    turn: {
      name: "",
    },
    isCorrect: false,
    incorrect: 0,
    gameOver: false,
  },
};

type Store = Readonly<typeof defaultState>

//Store Type
// type Store = {
//     readonly isConnected: boolean;
//     readonly currRoom: {
//         roomName: string;
//         currUser: {};
//         allUsers: {
//             name: string;
//             userId: string;
//         }[];
//     };
//     readonly gameState: {
//         owner: {};
//         remainingLetters: string[];
//         isChoosing: boolean;
//         ... 8 more ...;
//         gameOver: boolean;
//     };
// }**
Enter fullscreen mode Exit fullscreen mode

The Readonly utility type correctly infers type but it doesn’t add readonly to nested properties of an object. If we look into the implementation of the Readonly utility type.

type Readonly<T> = {
    readonly [P in keyof T]: T[P];
}
Enter fullscreen mode Exit fullscreen mode

Readonly utility adds readonly to all the top-level properties of the object but doesn't iterate over the nested object.

Creating custom utility

We can extend your knowledge of Readonly utility by using recursive types

type ReadonlyNested<S> = { readonly [K in keyof S]: ReadonlyNested<S[K]> };

// type Store = {
//     readonly isConnected: ReadonlyNested<boolean>;
//     readonly currRoom: ReadonlyNested<{
//         roomName: string;
//         currUser: {};
//         allUsers: {
//             name: string;
//             userId: string;
//         }[];
//     }>;
//     readonly gameState: ReadonlyNested<...>;
// }
Enter fullscreen mode Exit fullscreen mode

Let us go a step further and create another type of utility called expand

type expandTypes<T> = T extends infer O ? {
  [P in keyof O]: expandTypes<O[P]>
} : never
Enter fullscreen mode Exit fullscreen mode


Now when we do expansion

const defaultState = {
  isConnected: false,
  currRoom: {
    roomName: "",
    currUser: {},
    allUsers: [
      {
        name: "",
        userId: "",
      },
    ],
  },
  gameState: {
    owner: {},
    remainingLetters: [""],
    isChoosing: false,
    guessWord: "",
    myTurn: false,
    selectedLetters: [""],
    correctSelectedLetters: [""],
    wrongSelectedLetters: [""],
    turn: {
      name: "",
    },
    isCorrect: false,
    incorrect: 0,
    gameOver: false,
  },
};

type ReadonlyNested<T> = {
    readonly [P in keyof T]: ReadonlyNested<T[P]>;
}

type expandTypes<T> = T extends infer O ? {
  [P in keyof O]: expandTypes<O[P]>
} : never

type Store = expandTypes<ReadonlyNested<typeof defaultState>>

// Store type

// type Store = {
//     readonly isConnected: ReadonlyNested<boolean>;
//     readonly currRoom: {
//         readonly roomName: string;
//         readonly currUser: {};
//         readonly allUsers: readonly {
//             readonly name: string;
//             readonly userId: string;
//         }[];
//     };
//     readonly gameState: {
//         ...;
//     };
// }
Enter fullscreen mode Exit fullscreen mode

Conclusion

After syncing the type definition with the store object makes a great developer experience. We can have more custom utility for typescript. Please add a custom utility that you find interesting in the commotion section.

Top comments (0)