0

In this TypeScript code, also repeated below, there is a function which builds up a partial object in the application's type system from component parts. However, it doesn't work with the noted error that all the result's properties are undefined in a way that prevents assigning anything else to them. (They're not undefined in the hover test shown, however.)

I tried using the KeyForValue strategy noted here but that failed with an issue that the original has when using an interface instead of a type. Removing the dependence on the Record utility type removed that error but left one about how that KeyForValue type was more strict than the 'one' string picked from a constant array.

I also tried using this strategy with a variadic tuple type which also didn’t work because (readonly [...K])[0] cannot be used to index the type that K is a keyof.

Is there another workaround for the design limitation listed in that issue, to get rid of this error but still be able to dynamically build up an object with keys and corresponding values passed in as separate parameters?


The code at the Playground link is:

interface HornPlayer {instrumentName: 'saxophone' | 'clarinet' | 'trumpet';}
interface ChordPlayer {instrumentName: 'piano' | 'organ' | 'vibraphone';}
interface BassPlayer {instrumentName: 'double bass' | 'tuba' | 'bass guitar';}
interface DrumPlayer {kitItemCount: number;}
type Instrumentalist = HornPlayer | ChordPlayer | BassPlayer | DrumPlayer;
interface JazzQuartet {
    horn: HornPlayer,
    chords: ChordPlayer,
    bass: BassPlayer,
    drums: DrumPlayer
}
const demoFn = function (
    instrumentalists : [HornPlayer, ChordPlayer, BassPlayer, DrumPlayer],
) : Partial<JazzQuartet> {
    //This array is hard-coded only to make the example simpler:
    const roleNamesInSameOrder = ['horn', 'chords', 'bass', 'drums'] as (keyof JazzQuartet)[];
    let result : Partial<JazzQuartet> = {};
    const roleNameOutsideLoop = roleNamesInSameOrder[0]; // 'horn'
    //Error ts(2322): Type 'HornPlayer' is not assignable to type 'undefined':
    result[roleNameOutsideLoop] = instrumentalists[0];
    result['horn'] = instrumentalists[0]; //this works fine though
    for (let index in roleNamesInSameOrder) {
        const roleName = roleNamesInSameOrder[index];
        const instrumentalist = instrumentalists[index] as (JazzQuartet[typeof roleName]);
        let test = result['horn']; //HornPlayer | undefined as expected
        let test2 = result[roleName]; //HornPlayer | ChordPlayer | BassPlayer | DrumPlayer | undefined as expected
        //Error ts(2322): Type 'HornPlayer' is not assignable to type 'undefined':
        result[roleName] = instrumentalist;
    }
    return result;
}
WBT
  • 2,249
  • 3
  • 28
  • 40
  • This looks like an instance of [ms/TS#30581](https://github.com/microsoft/TypeScript/issues/30581), and the recommended fix is refactoring as per [ms/TS#47109](https://github.com/microsoft/TypeScript/pull/47109). If I do that I get [this code](https://tsplay.dev/NVkYZm). Does that address your question? If so I can write up an answer; if not, what am I missing? – jcalz Aug 27 '22 at 13:20
  • In the non-simplified code this is derived from, `roleNamesInSameOrder` is a parameter dynamically determined at runtime (per comment), and I don't think it can be hard-coded into an `OrderedRoles` interface like what's shown without some pretty major refactoring surgery as part of a JS->TS conversion. However, I'll go back and see what appetite there might be for doing that if that's what it takes. – WBT Aug 30 '22 at 01:34
  • I don't quite get it... is `instrumentalists` also determined at runtime? And if so, why is it defined as type `[HornPlayer, ChordPlayer, BassPlayer, DrumPlayer]` in the example? – jcalz Aug 30 '22 at 01:46
  • Yes, `instrumentalists` is also determined at runtime. I tried to simplify the example to eliminate some sources of variance/confusion while TypeScript still threw the error. The function is one that changes the shape from two arrays in which one has the key and the other the corresponding value into a key-value object structure that is more generally useful. It works, but I don't know just how to communicate that to TypeScript. – WBT Aug 30 '22 at 01:49
  • ‍♂️ It's hard to know how to proceed if your example code is simpler than needed to demonstrate the situation. If you don't know that `instrumentalists` is `[HornPlayer, ChordPlayer, BassPlayer, DrumPlayer]`, then what type is it? – jcalz Aug 30 '22 at 01:54
  • Well, it could be that, or it might e.g. have the first two switched - but if it did, the first two in roleNamesInSameOrder would also be switched, so there's always a correspondence by position. The example code always has to be simpler than the real code and when the answer is 'major refactoring' that makes it harder to predict which simplifications will obscure why a particular refactoring might not be feasible. I did include the comment indicating roleNamesInSameOrder is only hardcoded for the simplified example. I appreciate the comments and aim to dive in deeper later this week. – WBT Aug 30 '22 at 02:00
  • @jcalz I've done some refactoring, producing [this](https://tsplay.dev/w1paYW) as a version that is both closer to the refactored original code and simpler as an example, throwing the same error but maybe not for the same reason. Should that be a new question or is it similar enough to this one? – WBT Aug 31 '22 at 20:08
  • It's up to you whether you want to [edit] this question or open a new one. The new version would probably be solved with generics like [this](https://tsplay.dev/WKRKMm) or [this](https://tsplay.dev/WJ51lm). Let me know if that works for you and where the question is and I can answer. Unless I'm missing something about this too. – jcalz Aug 31 '22 at 20:14
  • @jcalz Thanks much! Either of those two solutions work; listing both is useful. I put a new question [here](https://stackoverflow.com/q/73561966/798371) to avoid invalidation of points in the existing answer below. The code is the same plus two line breaks in comments. – WBT Aug 31 '22 at 21:16

1 Answers1

1

This type:

const roleNamesInSameOrder = ['horn', 'chords', 'bass', 'drums'] as (keyof JazzQuartet)[];

Is not safe, because you can repeat drums twice or even three times:

const roleNamesInSameOrder = ['drums', 'drums', 'drums'] as (keyof JazzQuartet)[];

You will see that there are no TS errors. Please also keep in mind, that:

    const roleNameOutsideLoop = roleNamesInSameOrder[0]; // 'horn'

roleNameOutsideLoop is infered as a keyof JazzQuartet and not as a horn.

To make this line safer, you need to use TupleUnion utility type. Here you can find more examples in my article.

interface HornPlayer { instrumentName: 'saxophone' | 'clarinet' | 'trumpet'; }
interface ChordPlayer { instrumentName: 'piano' | 'organ' | 'vibraphone'; }
interface BassPlayer { instrumentName: 'double bass' | 'tuba' | 'bass guitar'; }
interface DrumPlayer { kitItemCount: number; }
type Instrumentalist = HornPlayer | ChordPlayer | BassPlayer | DrumPlayer;
interface JazzQuartet {
    horn: HornPlayer,
    chords: ChordPlayer,
    bass: BassPlayer,
    drums: DrumPlayer
}

// stolen from here https://github.com/microsoft/TypeScript/issues/13298#issuecomment-692864087
type TupleUnion<U extends string, R extends any[] = []> = {
    [S in U]: Exclude<U, S> extends never ? [...R, S] : TupleUnion<Exclude<U, S>, [...R, S]>;
}[U];

const demoFn = function (
    instrumentalists: [HornPlayer, ChordPlayer, BassPlayer, DrumPlayer],
): Partial<JazzQuartet> {

    //This array is hard-coded only to make the example simpler:
    const roleNamesInSameOrder: TupleUnion<keyof JazzQuartet> = ['horn', 'chords', 'bass', 'drums'];
    let result: Partial<JazzQuartet> = {};

    const roleNameOutsideLoop = roleNamesInSameOrder[0]; // 'horn'

    result[roleNameOutsideLoop] = instrumentalists[0]; // ok

    result['horn'] = instrumentalists[0]; // ok

    for (let index in roleNamesInSameOrder) {
        const roleName = roleNamesInSameOrder[index];
        const instrumentalist = instrumentalists[index] as (JazzQuartet[typeof roleName]);
        let test = result['horn'];
        let test2 = result[roleName];

        result[roleName] = instrumentalist; // error
    }
    return result;
}

However, we still have an error here result[roleName] = instrumentalist. I understand that you are a bit confused, because, this code works as expected:

result[roleNameOutsideLoop] = instrumentalists[0]; // ok

This works without an error, because TS is able to infer roleNameOutsideLoop which is horn and instrumentalists[0] which is HornPlayer.

However, here:

result[roleName] = instrumentalist; // error

roleName is a union of "horn" | "chords" | "bass" | "drums" and instrumentalist is a union of HornPlayer | ChordPlayer | BassPlayer | DrumPlayer.

It means that from type perspective next case can have a place:

// pseudo code
result['horn'] = BassPlayer

TS is not able to track index in a for..in loop. TS is not smart enough to figure out that there is a relation between index, roleNamesInSameOrder and instrumentalists. I mean, each time when you are using roleNamesInSameOrder[index] you get a union instead of some only one type.

Please see related question and my article.

SUMMARY

Usually, in TypeScript, when you have such issue, you need to use Array.prototype.reduce:

interface HornPlayer { instrumentName: 'saxophone' | 'clarinet' | 'trumpet'; }
interface ChordPlayer { instrumentName: 'piano' | 'organ' | 'vibraphone'; }
interface BassPlayer { instrumentName: 'double bass' | 'tuba' | 'bass guitar'; }
interface DrumPlayer { kitItemCount: number; }
type Instrumentalist = HornPlayer | ChordPlayer | BassPlayer | DrumPlayer;
interface JazzQuartet {
    horn: HornPlayer,
    chords: ChordPlayer,
    bass: BassPlayer,
    drums: DrumPlayer
}

// stolen from here https://github.com/microsoft/TypeScript/issues/13298#issuecomment-692864087
type TupleUnion<U extends string, R extends any[] = []> = {
    [S in U]: Exclude<U, S> extends never ? [...R, S] : TupleUnion<Exclude<U, S>, [...R, S]>;
}[U];

const ROLE_NAMES: TupleUnion<keyof JazzQuartet> = ['horn', 'chords', 'bass', 'drums'];


const fn = (
    instrumentalists: [HornPlayer, ChordPlayer, BassPlayer, DrumPlayer],
): Partial<JazzQuartet> =>
    ROLE_NAMES.reduce((acc, elem, index) => ({
        ...acc,
        [elem]: instrumentalists[index]
    }), {} as Partial<JazzQuartet>)

Playground

P.S. TypeScript does not like mutations

  • I'm not sure why you're not just changing it to `const roleNamesInSameOrder = ['horn', 'chords', 'bass', 'drums'] as const;`. And the `reduce()` is not safe, it just has no error because `{...acc, [elem]: instrumentalists[index]}` will always be assignable to `typeof acc`. I don't know if there's a compiler-verified type safe way to do what they're doing without significant refactoring (like, why use arrays in the first place instead of just `JazzQuartet`)? In their place I'd just use a type assertion. – jcalz Aug 27 '22 at 13:02
  • @jcalz because when you use `as const` you need to manually check whether each key is used in a tuple. And I have also used type assertion in reducer, just without mutation – captain-yossarian from Ukraine Aug 27 '22 at 13:08
  • You don't need to assert that `{}` is `Partial`, since it definitely is one. The issue is that you can change the added property to `[elem]: instrumentalists[3 - index]` and there's no compiler error. It has no idea and is not trying to track that `instrumentalists[index]` is correlated properly with `elem`. This question looks like another instance of [ms/TS#30581](https://github.com/microsoft/TypeScript/issues/30581) to me. – jcalz Aug 27 '22 at 13:17