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 };
1

There are 1 best solutions below

4
On BEST ANSWER

Your desired BaseConfig is really more of a self-referential generic constraint than a specific type in TypeScript. That is, given a specific candidate type T, you can check if it is assignable to a BaseConfigConstraint<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 of BaseConfigConstraint<T>. Something like this:

const asBaseConfig = <T extends
  { default: Partial<EnvironmentConfig> } &
  Record<Environment,
    Partial<EnvironmentConfig> &
    Omit<EnvironmentConfig, keyof T['default']>
  >
>(baseConfig: T) => baseConfig;

Here we are saying that baseConfig must be of a type T with:

  • a default property of a subtype of Partial<EnvironmentConfig>, and
  • properties at Environment keys of subtypes of both:
    • Partial<EnvironmentConfig>, and
    • an object with any properties of EnvironmentConfig that are missing from the default property of T.

In other words, T must have keys from Environment as well as "default", all of which must have Partial<EnvironmentConfig> properties, but any properties missing from the default property must be present in the Environment ones.

Let's see how it works on your example:

const baseConfig = asBaseConfig({
  default: {
    isCustomerFacing: false
  },
  local: {
    serverUrl: 'https://local.example.com'
  },
  prod: { // error!
//~~~~ <-- Property 'serverUrl' is missing 
    isCustomerFacing: true,
  }
});

This is exactly the error you wanted. You can fix the error either by adding serverUrl to prod or to default. 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