Photo by Alex Blฤjan on Unsplash
Dynamic optional and mandatory fields with advanced Typescript generics
Typescript builder tutorial [5/6]
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 thatP 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 typeT
creates a new one, only constituted of the properties provided inP
:
type PickedXY = Pick<{ x: string, y: number, z: boolean }, 'x' | 'y'> // { x: string, y:number }
NonNullable
: Removesnull
andundefined
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
callsBuilderWithTypeMethods
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 potentiallyundefined
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 theK
property - The
as
keyword can use template string to construct complexe transformation of theK
name. In our case we use the built-inCapitalize
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 :
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