Consider the following type definitions in TypeScript:
enum Environment {
Local = 'local',
Prod = 'prod'
}
type EnvironmentConfig = {
isCustomerFacing: boolean,
serverUrl: string
}
type DefaultBaseConfig<T> = {
default: T
}
type EnvironmentBaseConfig<T> = {
[key in Environment]: T
}
type BaseConfig<T> = DefaultBaseConfig<T> | EnvironmentBaseConfig<T>;
// const baseConfig: ??? = {
const baseConfig: BaseConfig<Partial<EnvironmentConfig>> = {
default: {
isCustomerFacing: false
},
local: {
serverUrl: 'https://local.example.com'
},
prod: {
isCustomerFacing: true
}
};
Notice the object at the end with const baseConfig: ??? and the Partial<> on the next line. What I really want is for baseConfig to allow each Environment-keyed property to be a Partial<EnvironmentConfig>, and to allow the default property to be the same, but also require that the intersection between default and each environment local and prod in turn must be a full (not Partial<>) EnvironmentConfig, thus fulfilling the requirements of some type along the lines of BaseConfig.
In the example case here, local would be valid because when combined with default it has both properties. But, prod would not be valid because no serverUrl has been declared between the intersection of default and prod.
Obviously, at a later time, this config object will be merged conditionally in some code that takes a BaseConfig and an environmentName and returns an EnvironmentConfig, and it will be guaranteed to work at runtime if it is given a static config object that has been inspected by Typescript to comply with the desired constraints.
I have been puzzling away at this for a while and am stuck. I know how to do conditional types and type constraints such as T extends U ? T : never, but can't seem to figure out how and where to apply that to this scenario.
How can I achieve this goal?
Here is my best attempt so far, but, of course it doesn't work:
type SplitWithDefault<
TComplete,
TDefault extends Partial<TComplete>,
TSplit extends { default: TDefault, [key: string]: Partial<TComplete> }
> = { default: TDefault }
& { [P in keyof Omit<TSplit, 'default'>]: (TSplit[P] & TDefault) extends TComplete ? TSplit[P] : never };
Your desired
BaseConfigis really more of a self-referential generic constraint than a specific type in TypeScript. That is, given a specific candidate typeT, you can check if it is assignable to aBaseConfigConstraint<T>rule, but you'd find it hard/impossible to express "all types that adhere to this rule" in a single TypeScript object type.In cases like this I usually write a helper identity function that takes a single argument and returns it, but only accepts arguments of a type
T extends BaseConfigConstraint<T>for a suitable definition ofBaseConfigConstraint<T>. Something like this:Here we are saying that
baseConfigmust be of a typeTwith:defaultproperty of a subtype ofPartial<EnvironmentConfig>, andEnvironmentkeys of subtypes of both:Partial<EnvironmentConfig>, andEnvironmentConfigthat are missing from thedefaultproperty ofT.In other words,
Tmust have keys fromEnvironmentas well as"default", all of which must havePartial<EnvironmentConfig>properties, but any properties missing from thedefaultproperty must be present in theEnvironmentones.Let's see how it works on your example:
This is exactly the error you wanted. You can fix the error either by adding
serverUrltoprodor todefault. So that's good.Note that having a constraint instead of a type means that any function or type which you were going to give a parameter or a property of type
BaseConfigwill now need to be a generic function or type with a type parameter corresponding to this constraint. This may or may not be something you're willing to do in your code base.Playground link to code