5

The following code block produces a typescript error, because although we know that foo[k] and bar[k] are the same type, TS cannot know (well, maybe by some magic it could, but apparently it doesn't)

interface IMixed {
    a: number;
    b: string;
    c: boolean;
}

const foo: IMixed = { a: 1, b: 'one', c: true };

const bar: IMixed = { a: 2, b: 'two', c: false };

(Object.keys(foo) as Array<keyof IMixed>).forEach(k => {
    foo[k] = bar[k]; 
//  ^^^^^^ Type 'string | number | boolean' is not assignable to type 'never'.
});

When TS can't figure something out that I know to be true, I cast it. But in this case, although I know this is a valid assignment, I don't know which type it is ... all I know is they are the same. I couldn't quite work out an elegant way to use a generic here.

Assume I am committed to the practice of iterating over these properties (say, because we expect properties do be added down the line, and similar code exists all over the codebase ...)

In short: How do I assert that this assignment is valid?

(side question: why does the error refer to the assignee as type 'never'?)

UPDATE

@captain-yossarian makes a good point about mutability, and presented a totally viable immutable solution. I feel that the question is still open, though, if the example is made a little tricker:

interface IMixed {
    a: number[];
    b: string[];
    c: boolean[];
}

const foo: IMixed = { a: [1], b: ['one'], c: [true] };
const bar: IMixed = { a: [2], b: ['two'], c: [false] };

function intersection<T>(...arrays: T[][]): T[] {
    return [...new Set([].concat(...arrays))]
}

(Object.keys(foo) as Array<keyof IMixed>)
  .reduce((acc, elem) => ({
    ...acc,
    [elem]: intersection(foo[elem], bar[elem])
                      // ^^^^^^^^^ Argument of type 'string[] | number[] | boolean[]' is not 
                      // assignable to parameter of type 'string[]'.
  }), foo);

The point is, assignment obviously requires that the types be compatible ... but so does this intersection function. So this is like the immutable version of the original question.

geofh
  • 535
  • 3
  • 15
  • Presumably you're looking for something more nuanced and helpful than `// @ts-ignore` ;-) – T.J. Crowder Oct 19 '21 at 13:05
  • love @ts-ignore ;-) ... but the linter won't let me :) – geofh Oct 19 '21 at 13:07
  • @geofh made an update. now is it possible to mutate `foo` – captain-yossarian from Ukraine Oct 19 '21 at 13:37
  • 1
    In above example `k` is a union `'a' | 'b' | 'c'`. In order to narrow value type TS needs **specific** key. You could extract and use generic function accepting specific `keyof IMixed` https://www.typescriptlang.org/play?#code/JYOwLgpgTgZghgYwgAgJIFlgA8IBNkDeAsAFDLnJwBcyIArgLYBG0A3KRckzQM5hSgA5uzIUENJgHtJAGwhwQIgL6lSCSSD7IY0mhmx5kAXkKUaARgA0XGgHINEW9fHJ+dFEpFqNWpnCh6mDj4JgRmyABM1tzItmAA7pJOyC7wMjweXiQAFADyTABWEAhgAHQA1hAAnjzZOpIAlJQ8yACCUFBwVQA8lVWSMGhBeAB8DaU6UACiiAAW2eoADlUNWTB0ICXAGimSy90A0sgQWJAguC19A0MGuCPZ5TQHTcSi5PUA2uUAusZc-l9vsogA – Aleksey L. Oct 19 '21 at 14:00
  • `never` means that situation can't exist with the types you've defined. For example, try accessing `let a: number = 0`; within an if block, like `if(typeof a === 'string')`. – Connor Low Oct 19 '21 at 17:55
  • Thanks @AlekseyL. ... I thought of doing something like this, and in this simplified example it means re-implementing the assignment operator (!). In a more complex case the operator function would seem less trivial and therefore more elegant ... so, a valid suggestion for sure – geofh Oct 19 '21 at 18:04

3 Answers3

2

It is worth using reduce instead of forEach in this case:

interface IMixed {
  a: number;
  b: string;
  c: boolean;
}

const foo: IMixed = { a: 1, b: 'one', c: true };

const bar: IMixed = { a: 2, b: 'two', c: false };

(Object.keys(foo) as Array<keyof IMixed>)
  .reduce((acc, elem) => ({
    ...acc,
    [elem]: bar[elem]
  }), foo);

Playground

Mutations does not work well in TypeScript.

See related questions: first, second, third, forth and my article

UPDATE

(Object.keys(foo) as Array<keyof IMixed>).forEach(k => {
    foo[k] = bar[k]; // error
});

You have an error here, because forEach as well as reduce is dynamic. bar[k] is a union of number | string | boolean, as well as foo[k]. It means that inside the loop it would be possible to assign foo['a'] = foo['c']. Both values are valid, since we expect a union of number | string | boolean but it also would be unsafe. That's why TS forbids this behavior.

From the other hand, reduce works, because we create new object which extends IMixed instead of mutating

UPDATE

In order to be able to mutate foo you need add indexing to IMixed interface:

type IMixed= {
  a: number;
  b: string;
  c: boolean;
  [prop: string]: number | boolean | string
}

const foo: IMixed = { a: 1, b: 'one', c: true };

const bar: IMixed = { a: 2, b: 'two', c: false };


(Object.keys(foo) as Array<keyof IMixed>).forEach(k => {
  foo[k] = bar[k];
});

Playground

  • Ugh. I dislike `reduce` at the best of times (outside of functional programming with predefined, reusable, tested reducers), but without even using its result value...blech. :-) Is there really no better way? – T.J. Crowder Oct 19 '21 at 13:12
  • I think this is a fair use of reduce, and I agree with the statement on immutability. Unfortunately it works only because I reduced (no pun intended) the example to a simple form ... in the actual situation I can't escape the problem quite so easily. @captain-yossarian Not sure how to remedy that, if I update the question to defeat your answer that won't make you look good :) – geofh Oct 19 '21 at 13:16
  • @geofh - Ouch, does it invalidate both answers? And do any of the related questions help? *(Edit: Never mind, I deleted mine. The point about `foo` being possibly a subtype of `IMixed` makes it dangerous.)* – T.J. Crowder Oct 19 '21 at 13:17
  • @T.J.Crowder I made an update. – captain-yossarian from Ukraine Oct 19 '21 at 13:18
  • 1
    Thanks! So the key thing is creating a new object. Your point about the issue with using the keys from `foo` is a **very** good point (as always). – T.J. Crowder Oct 19 '21 at 13:19
  • @T.J.Crowder thank you very much! – captain-yossarian from Ukraine Oct 19 '21 at 13:19
  • But the problem remains that `foo` is not updated (neither the variable -- well, it can't be, it's a `const` -- nor the object), and in fact the new object being created isn't even stored anywhere. – T.J. Crowder Oct 19 '21 at 13:23
  • Maybe I would recompose the question to avoid the discussion of mutability/immutability ... imagine say, a complex pair-wise test of two instances. Need some time to think about it ... watch this space :) – geofh Oct 19 '21 at 13:29
  • @T.J.Crowder geofh made an update – captain-yossarian from Ukraine Oct 19 '21 at 13:32
  • 1
    Cool. :-) Fancy poking holes in [this](https://stackoverflow.com/a/69631938/157247)? :-) – T.J. Crowder Oct 19 '21 at 13:41
  • 2
    @captain-yossarian I haven't gotten back to composing a harder-to-get-around scenario. But I'm sure that the indexing here is a no-no ... I've suffered quite a bit from this practice (by others) because it breaks type safety. Don't you find? – geofh Oct 19 '21 at 17:08
  • @geofh it depends. You cant assign `string` to `foo[a]`. But in general, yes, indexing is not safe. Your scenario is tricky. TypeScript does not play well with mutations and does not track mutations except one case https://stackoverflow.com/questions/68643123/why-does-typescript-track-mutation-of-function-static-properties . If you still want to stick with mutations I think you better stick with T.J. Crowder solution. Personally, I don't like mutations, but this is the matter of the style. Hence, if you want to make it type safe - don't mutate. Otherwise it is a trade off – captain-yossarian from Ukraine Oct 19 '21 at 17:24
  • @captain-yossarian Totally with you on the subject of mutations :) But I think I've come up with an equally problematic example, without using an assignment operator. See what you think. – geofh Oct 19 '21 at 17:35
1

captain-yossarian usefully (as always) points out that you can't rely on the Object.keys(foo).forEach to pick valid keys, because it provides all of the keys of foo, but foo could be a supertype of IMixed that has properties bar doesn't have. Also, just because foo and bar both have an a property (for instance)

It seems to work to base the loop on the keys of the target (foo) and doing a check that the key exists in the source (bar) while requiring that they share a common subtype via a generic parameter, like this:

function copyAllProps<ObjectType>(target: ObjectType, source: ObjectType) {
    // The `as` below is a bit of a lie :-D
    (Object.keys(target) as Array<keyof ObjectType>).forEach(k => {
        // NOTE: The type of `k` here is slightly wrong. We told TypeScript
        // it's `keyof ObjectType`, but it could be something else, because
        // `target` can have keys that `source` doesn't have (`target`'s type can
        // be a supertype of `source`'s type). This `if` mitigates that by
        // checking that `source` does have the property.
        if (k in source) {
            target[k] = source[k];
        }
    });
}

(Note the caveats within.) That only copies the common properties.

Usage examples:

// Your original examples
let foo: IMixed = { a: 1, b: 'one', c: true };
let bar: IMixed = { a: 2, b: 'two', c: false };

let bar2 = { a: 2, b: false, c: 'two'};

let foo3 = { a: 1, b: 'one', c: true, d: new Date() };
let bar3 = { a: 2, b: 'two', c: false, d: null };

function test() {
    copyAllProps(foo, bar);     // <== Works
    copyAllProps(foo, bar2);    // <== Error as desired, same keys but different types for `b` and `c`
    copyAllProps(foo3, bar3);   // <== Error as desired, same keys but different types for `d`
    copyAllProps(foo, {});      // <== Error as desired, source isn't a match
}

Playground link

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
1

Here's a solution to the updated problem (the immutable version) ... taking inspiration from both @captain-yossarian 's answer and @AlekseyL. 's comment.

function intersection<K extends keyof IMixed>(k: K, mixed1: IMixed, mixed2: IMixed) {
    return [...new Set([].concat(mixed1[k], mixed2[k]))]
}

(Object.keys(foo) as Array<keyof IMixed>)
  .reduce((acc, elem) => {
    return {
        ...acc,
        [elem]: intersection(elem, foo, bar)
    } 
}, foo);

or ... how about generalizing with something a little more beautiful:

function pairwiseProcess<TIn, TOut>(
    item1: TIn,
    item2: TIn,
    iteratee: <K extends keyof TIn>(k: K, item1: TIn, item2: TIn) => TOut
) {
    return (Object.keys(item1) as Array<keyof TIn>).reduce((acc, key) => {
        return {
            ...acc,
            [key]: iteratee(key, item1, item2)
        };
    }, {});
}

console.log(pairwiseProcess(foo, bar, intersection));

Sorry, tried to put this in https://www.typescriptlang.org/play but it shows an error that I don't see on my IDE (i can even run it with ts-node) ... ?

geofh
  • 535
  • 3
  • 15