2

I would like to dynamically extend a type so that I can do the following:

interface SurveyQuestion {
  propertyName: string,
  propertyType: Object,
}

const questions: SurveyQuestion[] = [
  { propertyName: "isNewUser", propertyType: typeof Boolean },
  { propertyName: "satisfactionScore", propertyType: typeof Number },
  { propertyName: "comments", propertyType: typeof String },
];

type SurveyWithQuestions = {
  date: Date;
  username: string;
}

let survey1: SurveyWithQuestions = { 
  date: new Date("1/1/2023"),
  username: "johndoe",
  isNewUser: true,
  satisfactionScore: 5,
  comments: "happy user"
};

...so that new questions added will not error and will allow code completion for them. This gets close using generics and object keys. But I couldn't modify it properly to iterate questions.

4
  • Why is this tagged "typescript-eslint"? Do you have some linter config that's relevant here? If so, what is it? If not, maybe untag? Commented Mar 11, 2023 at 17:40
  • 1
    typeof Boolean and typeof Number and typeof String are almost certainly not what you want; those are just the string "function". And Object is also almost certainly not what you want, since that matches any non-nullish value, including the string "function", which is why that's not an error. Could you please edit the code to be a minimal reproducible example that actually demonstrates what you're trying to do, at least the runtime code? Then we can help you give it typings. Commented Mar 11, 2023 at 17:42
  • Perhaps this approach is what you want, but I had to make a lot of guesses and I'm not sure how you expect to use it (where and when are you going to "add" new questions? How are you really going to encode what types the property types are? etc). If that works for you I could write up an answer explaining; otherwise, what am I missing? Commented Mar 11, 2023 at 17:51
  • @jcalz Yes, that is exactly how I expect to use it; adding new SurveyQuestions to the array automatically adds those strongly-typed properties to the SurveyWithQuestions object. That way I can use code completion and not have to keep property names in multiple places. Thanks for your TS wizardry! Commented Mar 11, 2023 at 21:00

1 Answer 1

2

I'm going to presume that the propertyType property will be a string that describes the type, like "boolean" or "date". If so, then for this to work you'll need to keep a type that maps from these strings to the TypeScript types they represent:

interface TypeMap {
    boolean: boolean;
    number: number;
    string: string;
    date: Date;
    // add any name-to-type mappings you need here
}

and then we can say that your SurveyQuestion type needs to use on of the keys of TypeMap as propertyType:

interface SurveyQuestion {
    propertyName: string,
    propertyType: keyof TypeMap,
}

When you define question, you don't want to annotate it as SurveyQuestion[]. You need the compiler to keep track of the particular propertyName and propertyType values inside the initializing array literal, but if you annotate the variable property as SurveyQuestion[] the compiler will forget all those details. So you want to write const questions = ... and not const questions: SurveyQuestion[] = ....

And even that's not quite enough; the compiler won't realize you care about the literal types of those property values unless you tell it so. Normally a value like [{a: "abc", x: "xyz"}, {a: "def", x: "uvw"}] will be inferred as type Array<{a: string, x: string}>, even without an annotation. It's more common for people to want a type like that then something very specific about "abc" and "xyz". But you want the most specific type possible, so you can do so with a const assertion:

const questions = [
    { propertyName: "isNewUser", propertyType: "boolean" },
    { propertyName: "satisfactionScore", propertyType: "number" },
    { propretyName: "comments", propertyType: "string" },
] as const;

And IntelliSense shows you that the type of questions is:

/* const questions: readonly [{
    readonly propertyName: "isNewUser";
    readonly propertyType: "boolean";
}, {
    readonly propertyName: "satisfactionScore";
    readonly propertyType: "number";
}, {
    readonly propretyName: "comments";
    readonly propertyType: "string";
}] */

So that's enough information to work with now, hooray!


But note that we haven't used SurveyQuestion at all. The compiler didn't realize that we wanted SurveyQuestions and so it didn't catch that I wrote propretyName instead of propertyName in there. Oops!

To fix that, you can use the new satisfies operator to have the compiler check that your initializer is an array of SurveyQuestions without widening the variable to that type:

const questions = [
    { propertyName: "isNewUser", propertyType: "boolean" },
    { propertyName: "satisfactionScore", propertyType: "number" },
    { propretyName: "comments", propertyType: "string" }, // error!
//    ~~~~~~~~~~~~~~~~~~~~~~~ <-- Did you mean to write 'propertyName'?
] as const satisfies ReadonlyArray<SurveyQuestion>;

Now the compiler has caught the error and we can fix it:

const questions = [
    { propertyName: "isNewUser", propertyType: "boolean" },
    { propertyName: "satisfactionScore", propertyType: "number" },
    { propertyName: "comments", propertyType: "string" },
    // { propertyName: "anotherProp", propertyType: "date" }
] as const satisfies ReadonlyArray<SurveyQuestion>;

Okay, great.


Now all we have to do is define SurveyWithQuestions in terms of questions. First we can get the type of questions using the TypeScript typeof type operator, and index into it with number to get the union if its element types:

type QuestionType = typeof questions[number];

/* type QuestionType = {
    readonly propertyName: "isNewUser";
    readonly propertyType: "boolean";
} | {
    readonly propertyName: "satisfactionScore";
    readonly propertyType: "number";
} | {
    readonly propertyName: "comments";
    readonly propertyType: "string";
} */

And now we can iterate over that union and remap it to object properties:

type ResponseFormat = {
    [T in QuestionType as T["propertyName"]]:
    TypeMap[T["propertyType"]]
}

/* type ResponseFormat = {
    isNewUser: boolean;
    satisfactionScore: number;
    comments: string;
} */

And finally, we can extend it with any other "static" properties to get SurveyQuestions:

interface SurveyWithQuestions extends ResponseFormat {
    date: Date;
    username: string;
}

Let's test it out:

let survey1: SurveyWithQuestions = {
    date: new Date("1/1/2023"),
    username: "johndoe",
    isNewUser: true,
    satisfactionScore: 5,
    comments: "happy user",
}; // okay

So that works as desired. Let's see what happens if we add a line to our questions definition:

const questions = [
    { propertyName: "isNewUser", propertyType: "boolean" },
    { propertyName: "satisfactionScore", propertyType: "number" },
    { propertyName: "comments", propertyType: "string" },
    { propertyName: "dateOfBirth", propertyType: "date" } // <-- add this
] as const satisfies ReadonlyArray<SurveyQuestion>;

let survey1: SurveyWithQuestions = { // error!
    date: new Date("1/1/2023"),
    username: "johndoe",
    isNewUser: true,
    satisfactionScore: 5,
    comments: "happy user",
}; 

// Property 'dateOfBirth' is missing in type ... // but required in type 'SurveyWithQuestions'.

So the compiler now expects a dateOfBirth property to exist. Let's give it one:

let survey1: SurveyWithQuestions = {
    date: new Date("1/1/2023"),
    username: "johndoe",
    isNewUser: true,
    satisfactionScore: 5,
    comments: "happy user",
    dateOfBirth: "yesterday" // error! 
    // Type 'string' is not assignable to type 'Date'.
};

Oops, wrong type:

let survey1: SurveyWithQuestions = {
    date: new Date("1/1/2023"),
    username: "johndoe",
    isNewUser: true,
    satisfactionScore: 5,
    comments: "happy user",
    dateOfBirth: new Date(Date.now() - 8.64e7)
}; // okay

Looks good!

Playground link to code

Sign up to request clarification or add additional context in comments.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.