Real World Example of Conditional Types
Conditional Types allows us to create a type that depends on another type. This is useful when we want to create a type that depends on the value of another type.
This article takes real world example of conditional types from Notifi SDK notifi-frontend-client
.
As can be seen from the flow below, we need to firstly create a configuration
object to be passed into newFrontendClient
to generate a client
object.
The problem is that the configuration factory fucntion newFrontendConfig
takes different arguments according to Blockchain type.
We want to create a more type safe way to avoid users passing wrong arguments to newFrontendConfig
.
This is how the "Conditional Types" comes in. Let go through the code step by step.
Define the possible configuration types
Firstly, we define the common properties of the configuration object.
export type NotifiEnvironmentConfiguration = Readonly<{
env: NotifiEnvironment;
tenantId: string;
}>;
Then we define the different configuration possible types. There are two possibilities:
- The blockchain that has accountAddress and publicKey will require the following config object:
export type NotifiConfigWithPublicKeyAndAddress = Readonly<{
walletBlockchain: "SUI" | "NEAR" | "INJECTIVE" | "APTOS" | "ACALA";
authenticationKey: string;
accountAddress: string;
}> &
NotifiEnvironmentConfiguration;
- The blockchain that has only publicKey will require the following config object:
export type NotifiConfigWithPublicKey = Readonly<{
walletBlockchain: "ETHEREUM" | "POLYGON" | "ARBITRUM" | "AVALANCHE" | "BINANCE" | "OPTIMISM" | "SOLANA";
walletPublicKey: string;
}> &
NotifiEnvironmentConfiguration;
- Finally, we can combine the two types into one type using
|
operator to create a union type.
export type NotifiFrontendConfiguration = NotifiConfigWithPublicKey | NotifiConfigWithPublicKeyAndAddress;
OK, It is where the problem comes out. 😨
As mentioned, we will have a factory function newFrontendConfig
to create the configuration object.
But how it possibly to let typescript know which type will be finally generated by newFrontendConfig
?
NotifiConfigWithPublicKey
or NotifiConfigWithPublicKeyAndAddress
?
Define the input type of the config factory function
In this section, we firstly go through the bad approach and then go check how to improve it.
- Bad approach
Define a type that contains all the possible properties and makes the blockchain specific properties optional.
In this case, we need to make account.address
optional.
export type FrontendClientConfigFactory = (args: {
account: Readonly<{
address?: string;
publicKey: string;
}>;
tenantId: string;
env: NotifiEnvironment;
walletBlockchain: NotifiFrontendConfiguration["walletBlockchain"];
}) => NotifiFrontendConfiguration;
Then we go next step creating a factory function that takes the above type as input.
export const newFrontendConfig: FrontendClientConfigFactory = (args) => {
switch (args.walletBlockchain) {
// Chains with only publicKey in account argument
case "ETHEREUM":
case "POLYGON":
case "ARBITRUM":
case "AVALANCHE":
case "BINANCE":
case "OPTIMISM":
case "SOLANA":
return {
tenantId: args.tenantId,
env: args.env,
walletBlockchain: args.walletBlockchain,
walletPublicKey: args.account.publicKey,
};
// Chains with publicKey and address in account arguments
case "SUI":
case "NEAR":
case "INJECTIVE":
case "APTOS":
case "ACALA":
return {
tenantId: args.tenantId,
env: args.env,
walletBlockchain: args.walletBlockchain,
authenticationKey: args.account.publicKey,
accountAddress: args.account.publicKey,
};
}
};
Ok, you might have realized the problems of this approach:
Not typesafe: Users could possibly pass wrong arguments to the factory function. ex. user might input
walletBlockchain: 'ETHEREUM'
and also passaccount: { address: '0x123x', publicKey: '0x123xx' }
to the factory function.However, there should not be the
address
property in theaccount
argument forETHEREUM
blockchain. In this case, Typescript should show an error to the user.Long and WET code: We need to repeat the same code for different chains using switch case.
- Good approach
Define two ConfigFacotryInput types explicitly.
export type ConfigFactoryInputPublicKeyAndAddress = {
account: Readonly<{
address: string;
publicKey: string;
}>;
tenantId: string;
env: NotifiEnvironment;
walletBlockchain: NotifiConfigWithPublicKeyAndAddress["walletBlockchain"];
};
export type ConfigFactoryInputPublicKey = {
account: Readonly<{
publicKey: string;
}>;
tenantId: string;
env: NotifiEnvironment;
walletBlockchain: NotifiConfigWithPublicKey["walletBlockchain"];
};
Then, combine the two types into one union type using |
operator.
export type ConfigFactoryInput = ConfigFactoryInputPublicKeyAndAddress | ConfigFactoryInputPublicKey;
Netx step is the most important part,
export type FrontendClientConfigFactory<T extends NotifiFrontendConfiguration> = (
args: T extends NotifiConfigWithPublicKeyAndAddress
? ConfigFactoryInputPublicKeyAndAddress
: ConfigFactoryInputPublicKey
) => NotifiFrontendConfiguration;
We create a ConfigFactory function type that takes a generic type T
as input.
Then, we can explicitly tell what input type should be passed into the factory function by conditional checking the generic type T
.
The we can start the implementation of the factory function.
There are two possible input types, so we need to create two factory functions.
const configFactoryPublicKey: FrontendClientConfigFactory<NotifiConfigWithPublicKey> = (args) => {
return {
tenantId: args.tenantId,
env: args.env,
walletBlockchain: args.walletBlockchain,
walletPublicKey: args.account.publicKey,
};
};
const configFactoryPublicKeyAndAddress: FrontendClientConfigFactory<NotifiConfigWithPublicKeyAndAddress> = (args) => {
return {
tenantId: args.tenantId,
env: args.env,
walletBlockchain: args.walletBlockchain,
authenticationKey: args.account.publicKey,
accountAddress: args.account.address,
};
};
Finally, we can combine the two factory functions into one factory function.
Here, we use a _type guard function* to check the input type so that user will get an proper error when they try to pass wrong arguments to newFrontendConfig
function.
const validateConfigInput = (config: ConfigFactoryInput): config is ConfigFactoryInputPublicKeyAndAddress => {
return "address" in config.account;
};
export const newFrontendConfig = (config: ConfigFactoryInput): NotifiFrontendConfiguration => {
return validateConfigInput(config) ? configFactoryPublicKeyAndAddress(config) : configFactoryPublicKey(config);
};
Congratulations! 🎉 Now we have a typesafe factory function that can create the config object for different chains.