2

Edit: Updated, minimal example (hopefully):

Paring it all down, I guess at the heart of my curiosity is why this won't work, and causes the two errors below:

interface Implicit {
  someNumber?: number | { value: number }
  someString?: string | { value: string }
}

interface Explicit {
  someNumber?: { value: number }
  someString?: { value: string }
}

const impl:Implicit = {
  someNumber: 123,
  someString: { value: 'impl' }
}

function convert (obj: Implicit) {
  const expl:Explicit = {}
  let k: keyof typeof obj;
  for (k in obj) {
    const objk = obj[k] // also, why does this alter behavior?
    if (typeof objk === 'object') {
      expl[k] = objk // Error {value:string}|{value:number} not assignable to {value:string}&{value:number}
    } else {
      expl[k] = { value: objk } // Error string|number is not assignable to never
    }
  }
}

Is TS not able to determine whether k is someNumber or someString each loop, so it's creating an intersection enforcing the result to be both?

Updated TS playground


Previous question:

I'm trying to create a versatile function that accepts a shorthand, implicit notation { someParameter: 123 } or an explicit version: { someParameter: { value: 123, min: 0, max: 200 }}, and converts and returns the explicit version:

interface ImplicitValues {
  color: number | string | ExplicitValueColor;
  position: number | ExplicitValuePosition;
}

interface ExplicitOptions {
  min?: number
  max?: number
}

interface ExplicitValueColor extends ExplicitOptions {
  value: number | string
}
interface ExplicitValuePosition extends ExplicitOptions{
  value: number,
}

There are other ImplicitValue types as well (not listed for brevity), so I'd like to create a generic type, that converts to the explicit type automatically:

type MakeExplicit<T> = {
    [K in keyof T]?: Extract<T[K], { value: any }>
}

This seems to work, as when we use mouse over the type using @jcalz awesome Expand utility, it is correct:

Picture of expanded type

However, when we try to loop through the ImplicitValues array, and make them explicit, we get the following error:

const someObject:ImplicitValues = {
  color: 0xff0000,
  position: {
    value: 123,
    min: 0,
    max: 200
  }
}

function isExplicit <T>(param: any): param is MakeExplicit<T> {
  if (typeof param === 'object') return 'value' in param;
  return false;
}

function convertToExplicit(obj:ImplicitValues) {
  let explicitValues:MakeExplicit<ImplicitValues> = {};

  let key:keyof typeof obj
  for (key in obj) {
    if (isExplicit(obj[key])) {
      explicitValues[key] = obj[key]
      // ERROR: Type 'string | number | ExplicitValueColor | ExplicitValuePosition' is
      // not assignable to type 'ExplicitValueColor & ExplicitValuePosition'.
    } else {
      explicitValues[key] = { value: obj[key] }
      // ERROR: Type 'string | number | ExplicitValueColor | ExplicitValuePosition' is
      // not assignable to type 'number'.
    }
  }
  return explicitValues
}

Setting these values manually works fine, but not dynamically in a loop. Do I cast explicitValues[key] = (obj as any)[key] and get it over with, or is there a better way to do this?

Link to TS playground, if that's helpful.

  • 1
    There's quite a bit going on in this code not directly related to your question; your `isExplicit()` type guard function doesn't seem to have a place from which to infer `T`, so it becomes `unknown`. You are testing `obj[key]` and then hoping it will be narrowed afterward, but that doesn't work in TS (see https://github.com/microsoft/TypeScript/issues/10530). The "unexpected intersection" is arguably due to https://github.com/microsoft/TypeScript/issues/30581, but there's so much other stuff going on right now. Could you pare down to a [mre] that doesn't have these other issues? – jcalz May 18 '22 at 02:28
  • 1
    And `Expand` seems unnecessary too. – jcalz May 18 '22 at 02:32
  • 1
    The refactoring necessary to put all this in a way the compiler can see is (mostly) type safe looks like [this](https://tsplay.dev/w1yRAw). I'd be happy to write up an explanation for some of it, but there are a lot of moving parts that are essentially digressions from your question (writing a generic `ExplicitValue`, making `isExplicit()` work, dealing with `Partial`, etc). If you could cut all of that out of the question so the answer doesn't have to either explain it or ignore it, that would be ideal. – jcalz May 18 '22 at 02:46
  • @jcalz: Thanks so much for the help! Yes, I had multiple questions, but I'll edit the original post and limit it to one. And your refactoring makes it work as intended, but being new to typescript, I'll definitely have to study your answer a bit more! – chieffancypants May 18 '22 at 21:35
  • 1
    Okay I'll write up an answer when I get a chance; might not be until tomorrow though – jcalz May 19 '22 at 02:34
  • 1
    I'm going to remove the optional modifier on the props of `Implicit` and turn back on `--strictNullChecks`, unless your question actually depends on these – jcalz May 19 '22 at 14:30
  • Sounds good, it works the same either way – chieffancypants May 19 '22 at 16:51
  • You've still got `// also, why does this alter behavior?` in that example code... are you trying to sneak another question in there? The answer to that is in https://github.com/microsoft/TypeScript/issues/10530 . – jcalz May 19 '22 at 18:24

1 Answers1

1

The primary issue here is that since k is of a union type, then the compiler sees obj[k] as having a union type too:

const objk = obj[k];
/*  string | number | { value: number; } | { value: string; } */

Even if you eliminate the primitives from that, obj[k] is still of a union type:

if (typeof objk === "object") {
  objk; /* { value: number } | { value: string } */
}

So all the compiler knows is that you have a value objk of type {value: number} | {value: string} and a key k of type keyof Implicit, and you are trying to assign expl[k] = objk where expl is of type Explicit. And in general, that is not safe. As far as the compiler can see, it's possible that k will be "someNumber" while objk will be of type {value: string}:

expl[k] = objk; // error!
// Type '{ value: number; }' is not assignable to type '{ value: string; }'

(If you're curious as to why the error message involves the intersection, see microsoft/TypeScript#30769. If you have an object t of type T and a (nongeneric) key k of type K1 | K2 and you want to assign a value to t[k], then the only safe thing to assign would be something applicable to both T[K1] and T[K2], or T[K1] & T[K2]. When you read t[k] you get T[K1] | T[K2], but when you write t[k] you need T[K1] & T[K2].)

We know that's impossible, because we know that both k and objk are correlated, but the type system can't track that. Hence the error. This lack of support for correlated union types is the subject of microsoft/TypeScript#30581.


The simplest way to deal with this is just to use a type assertion. When you know more than the compiler does about the type of some expression, you can just tell the compiler this information. Or you can just tell the compiler not to worry with an any assertion:

expl[k] = obj[k] as any; // I know what I'm doing

That's fine, but it takes some of the responsibility for type safety away from the compiler and places it on you. If you wrote the wrong thing the compiler won't catch it:

expl[k] = obj["someNumber"] as any; // oops

If you want to get some more type safety guarantees from the compiler, you will need to use generics instead of unions, and in particular you need to refactor your Explicit and Implicit types to be generic and depend on a common interface, so that the assignment expl[k] = objk is seen to be an assignment where both sides are the same in terms of that interface and a generic key K.

The approach is described in microsoft/TypeScript#47109, a fix for microsoft/TypeScript#30581 (interestingly enough the refactoring here works without the changes in that pull request, as most of the mechanics were already part of the language).

If you have an object t of type T, a key k of generic key type K, and a value v of type T[K] (which will also be generic), then the compiler will allow you to write t[k] = v, even though the same assignment would fail if K were a non-generic union. (This turns out to be unsafe for the same reason, but the compiler allows it. As long as we're careful not to specify K with a union-type, it's fine.)

Here's how it looks:

interface Base {
  someNumber: number;
  someString: string;
}

type Explicit<K extends keyof Base = keyof Base> =
  { [P in K]?: { value: Base[P] } };

type Implicit<K extends keyof Base = keyof Base> =
  { [P in K]: Base[P] | { value: Base[P] } };

The Explicit and Implicit types without a type parameter evaluate to the same as your original versions, but now you can write Explicit<K> for some particular key and the compiler will know that it has a(n optional) property at that key of the corresponding shape.

Now let's write a generic assign() function that performs the assignment you're doing in your for loop:

function assign<K extends keyof Base>(
  k: K,
  expl: Explicit<K>,
  objk: Base[K] | { value: Base[K] }
) {
  if (typeof objk === 'object') {
    expl[k] = objk  // okay
  } else {
    expl[k] = { value: objk }  // okay
  }
}

This compiles without errors, because in both cases you are assigning a value of type {value: Base[K]} to a property of the same generic type. And you can see you're getting more type safety than with the type assertion, since the compiler will complain about assigning random other stuff to expl[k]:

const obj: Explicit = null!    
expl[k] = obj; // error!

Once you have assign(), you can call it inside of convert():

function convert(obj: Implicit) {
  const expl: Explicit = {};
  let k: keyof typeof obj;
  for (k in obj) {
    assign(k, expl, obj[k]); // okay
  }
}

Note that you don't need to declare a separate assign() function, but you do need a generic function somewhere. It could be a callback to a forEach(), or an inline function:

function convert(obj: Implicit) {
  const expl: Explicit = {};
  let k: keyof typeof obj;
  for (k in obj) {
    (<K extends keyof Base>(
      k: K, expl: Explicit<K>, objk: Base[K] | { value: Base[K] }
    ) => expl[k] = (typeof objk === 'object') ? objk : { value: objk })(
      k, expl, obj[k]
    );
  }
}

And there you go! This refactoring might not be worth the trouble; a type assertion is also fine if you're careful enough.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Wow, amazingly clear. Thank you so much! After reading through some of those issues on github, I'm convinced you should write a book on all the little idiosyncratic behaviors of TS! Thanks again – chieffancypants May 20 '22 at 19:39