Pass implicit keys and values type relationship to TypeScript generic

Viewed 134

Target

Take care about substituting of isInvalid: false and validationPending: false by helper class/function:

const controlsAccess: {
    senderName: ControlAccess<string>;
    senderPhoneNumber: ControlAccess<number>;
    senderAddress: ControlAccess<Address>
} = {
    senderName: {
        validatablePayload: {
            value: "",
            isInvalid: false,
            validationPending: false
        },
        vueReferenceID: "SENDER_NAME"
    },
    senderPhoneNumber: {
        validatablePayload: {
            value: 0,
            isInvalid: false,
            validationPending: false
        },
        vueReferenceID: "SENDER_EMAIL"
    },
    senderAddress: {
        validatablePayload: {
            value: { city: "", street: "" },
            isInvalid: false,
            validationPending: false
        },
        vueReferenceID: "SENDER_ADDRESS"
    }
};

Types:

type ValidatableVModel<Value> = {
  isInvalid: boolean;
  value: Value;
  validationPending: boolean;
};

type ControlAccess<ValidatablePayload> = {
  validatablePayload: ValidatableVModel<ValidatablePayload>;
  vueReferenceID: string;
};

type Address = {
    city: string;
    street: string;
}

The final syntax will be like:

const controlsAccess: {
    senderName: ControlAccess<string>;
    senderPhoneNumber: ControlAccess<number>;
    senderAddress: ControlAccess<Address>
} = generateControlAccessObject({
   senderName: { vueReferenceID: "SENDER_NAME", initialValue: "" },
   senderPhoneNumber: { vueReferenceID: "SENDER_NAME", initialValue: 0 },
   senderAddress: { vueReferenceID: "SENDER_ADDRESS", initialValue: { city: "", street: "" } }
})

The JavaScript solution is trivial:

function generateControlAccessObject(dynamicData) {
    const accumulatingResult = {};
    for (const [ key, data ] of Object.entries(dynamicData)) {
      accumulatingResult[key] = {
         validatablePayload: {
             value: data.initialValue,
             isInvalid: false,
             validationPending: false
         },
         vueReferenceID: data.vueReferenceID
      }
    }
    return accumulatingResult;
}

console.log(generateControlAccessObject({
   senderName: { vueReferenceID: "SENDER_NAME", initialValue: "" },
   senderPhoneNumber: { vueReferenceID: "SENDER_NAME", initialValue: 0 },
   senderAddress: { vueReferenceID: "SENDER_ADDRESS", initialValue: { city: "", street: "",  } },
}))

Now how to explain TypeScript what is the parameter and what will be returned?

Solution on conceptual level

Let TypeScript know the implicit keys and types relationship:

  • senderName: string
  • senderPhoneNumber: number
  • senderAddress: Address

My efforts

First the type of controlsAccess__updatedAPI and what return generateInputsAccessObject is incompatible. Next, the accumulatingResult is initially empty which does not match with type annotation. Finally, TypeScript currently don't know what inside data.

Fiddle

const controlsAccess__updatedAPI: {
    senderName: ControlAccess<string>;
    senderPhoneNumber: ControlAccess<number>;
    senderAddress: ControlAccess<Address>
} = generateInputsAccessObject({
   senderName: { vueReferenceID: "SENDER_NAME", initialValue: "" },
   senderPhoneNumber: { vueReferenceID: "SENDER_NAME", initialValue: 0 },
   senderAddress: { vueReferenceID: "SENDER_ADDRESS", initialValue: { city: "", street: "",  } },
})

type DynamicData<Keys extends string, Values> = Record<Keys, SingleControlDynamicData<Values>>;
type SingleControlDynamicData<Values> = { initialValue: Values; vueReferenceID: string; };

function generateInputsAccessObject<Keys extends string, Values>(
    dynamicData: DynamicData<Keys, Values>
): Record<Keys, { initialValue: Values; vueReferenceID: string; }> {

    const accumulatingResult: Record<Keys, { initialValue: string; vueReferenceID: string; }> = {};

    for (const [ key, data ] of Object.entries(dynamicData)) {
      accumulatingResult[key] = {
        validatablePayload: {
          value: data.initialValue,
          isInvalid: false,
          validationPending: false
        },
        vueReferenceID: data.vueReferenceID
      };
    }

    return accumulatingResult;
}

Requirements

  • Please no skipped types annotations
  • Please no any and object
3 Answers

I think this should work for you:

function generateInputsAccessObject<DD extends { [K in string]: { initialValue: any, vueReferenceID: string } }>(
    dynamicData: DD
): { [K in keyof DD]: ControlAccess<DD[K]['initialValue']>}  {

    const accumulatingResult = {} as { [K in keyof DD]: ControlAccess<DD[K]['initialValue']>};

    for (const [ key, data ] of Object.entries(dynamicData)) {
      accumulatingResult[key as keyof DD] = {
        validatablePayload: {
          value: data.initialValue,
          isInvalid: false,
          validationPending: false
        },
        vueReferenceID: data.vueReferenceID
      };
    }

    return accumulatingResult;
}

PLAYGROUND

Proper types are inferred.

Canonical Answer

You asked for a canonical answer - my interpretation (which may be wrong), is an answer that could be used for a variety of similar questions, i.e. a more generic approach. However your question seems fairly specific, so I have created this answer as a generalized approach which allows transformation of a source data type to a destination data type where:

  • Source and destination data types are different, but share common key values used to map the differing types
  • The source may have 0..n keys present, the destination keys must match the source keyset
  • TypeScript type safety is used as much as possible (neither 'object' nor 'any' are used)

Notes

I have simplified your data types, but this approach could be used for a variety of data structures.

Using a discriminator field allows tight binding of keys to corresponding data types, but discriminator values must be supplied as string literals for TypeScript to type-check this correctly. See this link for more information.

While you could use a generic to describe the source data fields, this won't offer protection against mismatched discriminators and data types e.g. using a generic type for ISourceField<T> (see snippet below) would allow this mismatch (discriminator says PHONE, but in fact it is a NAME object):

type Discriminator = 'NAME' | 'PHONE' | 'ADDRESS';

senderName: { discriminator: 'PHONE', sourceVaryingType: 'John Smith', sourceSameType: 'source field for senderName' },

interface ISourceField<T> {
    discriminator: Discriminator;
    sourceVaryingType: T;
    sourceSameType: string;
}

interface ISourceFields {
    senderName?: ISourceField<string>
    senderPhoneNumber?: ISourceField<number>
    senderAddress?: ISourceField<IAddress>
}

Similarly Record<K,V> has the same issue, in that it would allow mismatches between the discriminator and ISourceField<T>.

Code

interface IAddress {
    city: string;
    street: string;
}

// source data definitions
interface ISourceFieldName {
    discriminator: 'NAME';
    sourceVaryingType: string;
    sourceSameType: string;
}

interface ISourceFieldPhone {
    discriminator: 'PHONE';
    sourceVaryingType: number;
    sourceSameType: string;
}

interface ISourceFieldAddress {
    discriminator: 'ADDRESS';
    sourceVaryingType: IAddress;
    sourceSameType: string;
}

// ? allows source field to be optional (note you can't use ! on an interface)
interface ISourceFields {
    senderName?: ISourceFieldName;
    senderPhoneNumber?: ISourceFieldPhone;
    senderAddress?: ISourceFieldAddress;
}


// destination data definitions
interface IDestFieldName {
    discriminator: 'NAME';
    destVaryingType: string;
    destSameType: string;
}

interface IDestFieldPhone {
    discriminator: 'PHONE';
    destVaryingType: number;
    destSameType: string;
}

interface IDestFieldAddress {
    discriminator: 'ADDRESS';
    destVaryingType: IAddress;
    destSameType: string;
}

// ! allows dest field to be null, i.e. not exist (for a class, ! allows for simpler syntax within the transform() function, compared to using ?)
class DestFields {
    senderName!: IDestFieldName
    senderPhoneNumber!: IDestFieldPhone
    senderAddress!: IDestFieldAddress
}

type DestFragmentValue = DestFields[keyof DestFields];

function createFragment(key: keyof ISourceFields): DestFragmentValue {
    switch (key) {
        case 'senderName': return { discriminator: 'NAME', destSameType: '', destVaryingType: '' };
        case 'senderPhoneNumber': return { discriminator: 'PHONE', destSameType: '', destVaryingType: 0 };
        case 'senderAddress': return { discriminator: 'ADDRESS', destSameType: '', destVaryingType: { city: '', street: '' } };
        default: throw new Error(`Unexpected key value '${key}' in source data`);
    }
}

function transform(source: ISourceFields): DestFields {
    let dest = new DestFields()
    let key: keyof DestFields;
    for (key in source) {
        // create intialized fragment on dest object
        (dest[key] as DestFragmentValue) = createFragment(key);

        // set required values
        let src = source[key];
        if (src != undefined) {
            dest[key].discriminator = src.discriminator;
            dest[key].destVaryingType = src.sourceVaryingType;
            dest[key].destSameType = src.sourceSameType;
        }
    }

    return dest;
}

// run some tests
let testData = new Array<ISourceFields>(4);

testData[0] = {};

testData[1] = {
    senderName: { discriminator: 'NAME', sourceVaryingType: 'John Smith', sourceSameType: 'source field for senderName' },
};

testData[2] = {
    senderName: { discriminator: 'NAME', sourceVaryingType: 'John Smith', sourceSameType: 'source field for senderName' },
    senderPhoneNumber: { discriminator: 'PHONE', sourceVaryingType: 12345678, sourceSameType: 'source field for senderPhoneNumber' },
};

testData[3] = {
    senderName: { discriminator: 'NAME', sourceVaryingType: 'John Smith', sourceSameType: 'source field for senderName' },
    senderPhoneNumber: { discriminator: 'PHONE', sourceVaryingType: 12345678, sourceSameType: 'source field for senderPhoneNumber' },
    senderAddress: { discriminator: 'ADDRESS', sourceVaryingType: { city: 'Dark City', street: '34 Westfield Drive' }, sourceSameType: 'source field for senderAddress' }
};

console.log('***** Begin tests *****');
for (let i = 0; i < testData.length; i++) {
    let result = transform(testData[i]);
    console.log(`Test number ${i} has result: ${JSON.stringify(result)}`);
    console.log();
}
console.log('***** End tests *****');

TS Playground Link

Your data format has three keys: senderName, senderPhoneNumber and senderAddress, however you have not specified whether all three will always be present, or whether there may only be 0..n keys present. Assuming all three keys are always present then this is one way of doing it:

interface Address {
    city: string;
    street: string;
}

interface ValidatableVModel<T> {
    isInvalid: boolean;
    value: T;
    validationPending: boolean;
};

type Discriminator = 'SENDER_NAME' | 'SENDER_PHONE_NUMBER' | 'SENDER_ADDRESS';

interface ControlAccess<T> {
    validatablePayload: ValidatableVModel<T>;
    vueReferenceID: Discriminator;
};

interface ControlsAccess {
    senderName: ControlAccess<string>;
    senderPhoneNumber: ControlAccess<number>;
    senderAddress: ControlAccess<Address>
}

const controlsAccessInitialValue: ControlsAccess = {
    senderName: {
        validatablePayload: {
            value: '',
            isInvalid: false,
            validationPending: false
        },
        vueReferenceID: 'SENDER_NAME'
    },
    senderPhoneNumber: {
        validatablePayload: {
            value: 0,
            isInvalid: false,
            validationPending: false
        },
        vueReferenceID: 'SENDER_PHONE_NUMBER'
    },
    senderAddress: {
        validatablePayload: {
            value: { city: '', street: '' },
            isInvalid: false,
            validationPending: false
        },
        vueReferenceID: 'SENDER_ADDRESS'
    }
};

interface SingleControlDynamicData<T> {
    initialValue: T;
    vueReferenceID: Discriminator;
}

type SingleControlDynamicDataTypes = SingleControlDynamicData<string> | SingleControlDynamicData<number> | SingleControlDynamicData<Address>;
type DynamicData = Record<keyof ControlsAccess, SingleControlDynamicDataTypes>;

const testData: DynamicData = {
    senderName: { vueReferenceID: 'SENDER_NAME', initialValue: 'John Smith' },
    senderPhoneNumber: { vueReferenceID: 'SENDER_PHONE_NUMBER', initialValue: 12345678 },
    senderAddress: { vueReferenceID: 'SENDER_ADDRESS', initialValue: { city: 'Dark City', street: '34 Westfield Drive' } }
};

function generateInputsAccessObject(dynamicData: DynamicData): ControlsAccess {

    let accumulatingResult: ControlsAccess = controlsAccessInitialValue;

    let key: keyof ControlsAccess;
    for (key in dynamicData) {
        accumulatingResult[key].validatablePayload.value = dynamicData[key].initialValue;
        accumulatingResult[key].vueReferenceID = dynamicData[key].vueReferenceID;

        // not required since set to false within controlsAccessInitialValue
        //accumulatingResult[key].validatablePayload.isInvalid = false;
        //accumulatingResult[key].validatablePayload.validationPending = false;
    }

    return accumulatingResult;
}

const result = generateInputsAccessObject(testData);
console.log(`result = ${JSON.stringify(result)}`);

TS Playground Link

Related