Dynamic optional and mandatory fields with advanced Typescript generics

Typescript builder tutorial [5/6]

ยท

4 min read

Introduction

In order to have the correct undefined status for fields on the built objects, we need to be able to have the type of builder that evolves as methods are called upon him. The idea here will be to have the return type of any of the methods of the builder to be a builder type of a new-entity that is the initial entity with the additional field added to it.

Advanced generics at the rescue...

To do that we will need a parametric type that from one initial type extends it with a mandatory property. This could be done through this type:

type TypeWithAdditionalMandatoryProps<T, P extends keyof T> = T & {
  [K in NonNullable<keyof Pick<T, P>>]: NonNullable<Pick<T, P>[K]>
}

Some explanations about this type :

  • <T, P extends keyof T> : Tels the type has two parameters. Thanks to that P extends keyof T , it tells that the second parameters should be the name of one or more of the properties of the first parameter.
type NT<T, P extends keyof T> = {}
type OK = NT<{ x:string, y: number }, 'x'> // OK
type Erroneous = NT<{ x:string, y: number }, 'z'>  // Type '"z"' does not satisfy the constraint '"x" | "y"'.
  • The T & ... tells that it will extend the initial first type with another type
  • Pick<T, P> : From an object type T creates a new one, only constituted of the properties provided in P :
type PickedXY = Pick<{ x: string, y: number, z: boolean }, 'x' | 'y'> // { x: string, y:number }
  • NonNullable : Removes null and undefined types from a list of union types.
type NonNullableTypes = NonNullable< string | null | undefined | boolean > // string | boolean

As a whole we have a type TypeWithAdditionalMandatoryProps that from an input type T and a list of properties P of T build a new type T-Bis with all the properties P set as mandatory properties.

... with type recursion to spice things a bit

We can then enhance the BuilderWithTypeMethods as follow :

type BuilderWithTypeMethods<T> = EntityBuilder<T> & {
  [K in NonNullable<keyof T>]: (value: NonNullable<T[K]>) => BuilderWithTypeMethods<TypeWithAdditionalMandatoryProps<T, K>>
}

Here also some explanation might be needed

  • The type BuilderWithTypeMethods calls BuilderWithTypeMethods making it a recursive type. The recursion of type was not supported in the first version of typescript, and this now allow some advance type construction.
  • The addition of the NonNullable key word allow to get rid of potentially undefined methods when building optional properties from the input type.

The type now says that each methods of the builder returns a builder with a type being built is the same type but with an additional property.

Here a sample of usage :

const cheddarBuilderWithKInd = EntityBuilder.getAn(Ingredient)
  .kind(EKind.Cheese) // BuilderWithTypeMethods<TypeWithAdditionalMandatoryProps<Partial<Ingredient>, "kind">>
const cheddarBuilderWithKIndAndWeight = cheddarBuilderWithKInd
  .weightInGrams(170) // BuilderWithTypeMethods<TypeWithAdditionalMandatoryProps<TypeWithAdditionalMandatoryProps<Partial<Ingredient>, "kind">, "weightInGrams">>
const cheddar = cheddarBuilderWithKIndAndWeight
  .build() // TypeWithAdditionalMandatoryProps<TypeWithAdditionalMandatoryProps<Partial<Ingredient>, "kind">, "weightInGrams">

Dynamic property names

Let's add a last modification to rename the name of the method so that it is more builder like :

type BuilderWithTypeMethods<T> = EntityBuilder<T> & {
  [K in NonNullable<keyof T> as `with${Capitalize<string & K>}`] :
  (value: NonNullable<T[K]>) => BuilderWithTypeMethods<TypeWithAdditionalMandatoryProps<T, K>>
}
  • The keyword as allow to rename the K property
  • The as keyword can use template string to construct complexe transformation of the K name. In our case we use the built-in Capitalize type to construct a new string type with name being capitalized.
type Capitals = Capitalize<'abc' | 'def'> // 'Abc' | 'Def'

Conclusion

Thanks to the usage of typescript generics, few typescript built-in types and keys words like extends or NonNullable, we were able to build a type that evolves as we use it.

We can then use the our generic builder as follow : image.png It's magic ! ๐ŸŽ‰

So now we have a class, that a type level works as expected (build time), but our class need more polish to be used at running. Thanks to javascript proxies, we will be able to dynamically change the behavior of our builder object. This is what we will do in our next and last post of this typescript series.

If you are already familiar with advanced typescript features and just want to see what could be a convenient-to-use builder infrastructure that makes good use of those features, please have a look at this repo : berlingo-ts

ย