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
BaseConfig
is 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
baseConfig
must be of a typeT
with:default
property of a subtype ofPartial<EnvironmentConfig>
, andEnvironment
keys of subtypes of both:Partial<EnvironmentConfig>
, andEnvironmentConfig
that are missing from thedefault
property ofT
.In other words,
T
must have keys fromEnvironment
as well as"default"
, all of which must havePartial<EnvironmentConfig>
properties, but any properties missing from thedefault
property must be present in theEnvironment
ones.Let's see how it works on your example:
This is exactly the error you wanted. You can fix the error either by adding
serverUrl
toprod
or 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
BaseConfig
will 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