[TypeScript] Why typescript expects an intersection of types instead of a union

Hi Fellows!

I get the following error on line 18:

Type 'string | string[]' is not assignable to type 'string[] & string'.
  Type 'string' is not assignable to type 'string[] & string'.
    Type 'string' is not assignable to type 'string[]'.(2322)

Question: why typescript expects an intersection of string[] & string on line 18 instead of a union string[] | string.

Code snippet on StackBlitz:

Code snippet:

type InputParams = {
  projects: string[];
  createdDate?: string;
};

type OutputParams = {
  projects?: string[];
  createdDate?: string;
};

const paramKeys: Array<keyof OutputParams> = ['projects', 'createdDate'];

export const getOutputParams = (params: InputParams): OutputParams => {
  const outputParams: OutputParams = {};

  paramKeys.forEach((param) => {
    if (params[param]) {
      outputParams[param] = params[param];
    }
  });

  return outputParams;
};

Because you are assigning inside outputParams a value from inputParams.
All TS knows is that inputParams value are either string OR a string[], as defined in InputParams.

Which make sense: Input params is either a string createdDate OR a list of projects, hence your union.
You probably want to narrow the type even further.

Hope this helps :sparkles:

Coupled to :point_up:, you have said that the input must have a field “projects”, but the output only maybe has this. As the input is cloned to output, how can this be possible?

Can you also explain what you’re trying to achieve and why here? IME object field key/value manipulation is probably the most frustrating part of TS (though the reason for that is that TS is very carefully following JS semantics), but problems with it often end up indicating that the approach is wrong, so the “why” in particular might help a lot

Totally agree with that. That’s why in general I don’t like this imperative manipulation of objects.

The above could be easily done with a lot of other methods, that will keep TS happy, for example reducing the paramsKeys into an Output object, as it seems it’s your goal here:

paramKeys.reduce((a,k) => ({...a, ...{[k]: params[k]}}), {} as OutputParams)

(forgive the short variable name but it was only to illustrate a point :slight_smile: )

I am working with converting query params to params that should be send to server. (i.e. api request)

Thanks, seems like TS i happy with such a solution.

I have tweaked my own code snippet, commented lines are the old ones and it works without any TS errors;

  // const outputParams: OutputParams = {};
  let outputParams: OutputParams = {};

  paramKeys.forEach((param) => {
    if (params[param]) {
      // outputParams[param] = params[param];
      outputParams = { ...outputParams, [param]: params[param] };
    }
  });

So far I still do not understand why the following happens:

 outputParams[param] = params[param];
// in the snippet above, typescript
// expects an intersection of (string[] && string)
// instead of a union (string[] | string)

The issue is that you’re dynamically trying to set keys/values on an object based on another object. And when you do like:

if (params[param]) {
      outputParams[param] = params[param];

You’re saying:

if there is a truthy value when I use the string param (which I’ve coerced to force TS to pretend is a key of params) to access params, then set a property in ouputParams to that key + the value in params.

You aren’t saying “if the value of params[param] is exactly the same as what it is expected to be”. You’re just saying “if this value is a truthy value then set it whatever it is”, but the object upon which you’re setting it expects specific types of things, not just “any allowed truthy value”

OutputParams is an object that doesn’t have anything to do with InputParams. All TS knows is that you’re trying to set one of the values in inputParams as a value in outputParams. Those values are going to be one of string, string[] and undefined.

You can use the same type for input/output (just Params, which I think is actually what you want but I might be wrong). And you can set them directly, not dynamically in a loop. That will the typing much easier: you are using it as a dictionary, and setting the properties directly means you can ensure the types being set are correct.

You can use Object.keys et al to loop, but the issue is that JS specifies the return value of keys is an array of strings, and TS has to abide by that. You can fix this in your project by overwriting the type of Object.keys like (note this is copied from here):

type ObjectKeys<T> = 
  T extends object ? (keyof T)[] :
  T extends number ? [] :
  T extends Array<any> | string ? string[] :
  never;

interface ObjectConstructor {
  keys<T>(o: T): ObjectKeys<T>
}

(you would put that somewhere in your project in a file like my-types.d.ts)

This doesn’t fix one of the major issues you are going to have. Query params are a string. But the keys and values of that interface are typed. Types only exist prior to compile time. TS knows that there is absolutely no way it can check the types are correct. Anything of that ilk (parsing JSON has same issue) is outside of the type system. So you either have to bypass the type system: this is what type assertions do, eg as keyof Params, or you can use a more generic type (Record<string, string> for example), or you can use a validation library (not typescript-specific, but more modern ones generally provide methods for taking a validation schema & converting it to TS types in your code).

Again, the logic here I can’t quite figure out. Can you explain a little bit more what you’re trying to do – ie show a more realistic example? InputParams and OutputParams seem like they should be the same thing, and I’m somewhat confused as to the reason for paramKeys existing, particularly as it’s hardcoded.

2 Likes

And if you are still curious on why this is the behavior, is because again TS has no guarantees that dynamic properties from Input are going to be the same as the Output.

So to “play it safe” is telling you that if you really want to add values dynamically there then the value has to satisfy both condition: has to be a type that overlaps both value === string & string[].

2 Likes

Thanks @Marmiz @DanCouper !

Now it makes a bit more sense to me, I will dig deeper into this topic in the TS docs to better understand it.