Reputation: 1527
I am currently struggling with some sort of type inference.
At the moment, my object looks like this:
const config: Config = {
calculate({ answers }) {
answers.questionOne // <-- SHOULD be allowed
answers.questionUnknown // <-- Should NOT be allowed
},
questions: {
questionOne: {
title: "Test Title",
answers: ["answer-1", "answer-2", "answer-3"],
},
},
}
For my types, I am having the Config
type like this:
// The answers object looks like: { questionKey: answerKey }.
type CalculateCtx = { answers: Record<string, string> }
type Question = {
title: string;
answers: string[];
}
type Config = {
calculate(ctx: CalculateCtx): void;
questions: Record<string, Question>;
}
My goal now, is that I want to be able to type the object in a matter of very strict. I want the calculate function, to only type the answers
with the available question keys. So that answers
in the calculate function would be typed as { questionOne: "answer-1" | "answer-2" | "answer-3" }
and not as { [key: string]: string }
.
I know, that this is possible (since libraries like trpc and stitches.js do this stuff), but I find it hard to find out how to properly do this.
Can someone please help me.
Upvotes: 0
Views: 348
Reputation: 2916
An approach using 'as const'. I added Partial<>
just to allow 'unanswered' state (with the side effect of the types having additional | undefined
.
const QuestionSettings = {
questionOne: {
title: "Title 1",
answers: ["answer-1a", "answer-1b", "answer-1c"],
},
questionTwo: {
title: "Title 2",
answers: ["answer-2a", "answer-2b"],
},
} as const;
type AvailableAnsMap = {
[qName in keyof typeof QuestionSettings]:
typeof QuestionSettings[qName] extends { answers: Readonly<unknown[]> } ?
typeof QuestionSettings[qName]['answers'][number] : never
}
type Config = {
calculate(ctx: { answers: Partial<AvailableAnsMap> }): void;
questions: typeof QuestionSettings;
}
const config: Config = {
calculate({ answers }: { answers: Partial<AvailableAnsMap> }) {
answers.questionOne //"answer-1a" | "answer-1b" | "answer-1c" | undefined
answers.questionTwo //"answer-2a" | "answer-2b" | undefined
},
questions: QuestionSettings
}
let studentAns1: Partial<AvailableAnsMap> = { questionOne: 'answer-1c' };
let studentAns2: Partial<AvailableAnsMap> = { questionTwo: 'answer-2a' };
let studentAns3: Partial<AvailableAnsMap> = { questionOne: 'answer-1a', questionTwo: 'answer-2a' };
config.calculate({ answers: studentAns1 })
EDIT
Edited to show a way to handle multiple configs without repeating the helper type for every single question settings. The helper types at the top of the second code can be shared across different settings. There is a caveat about forgetting to replace some parts, though.
/* definitions of helper types */
type AnswerRecordsNoBlanks<Qtype> = {
[qName in keyof Qtype]:
Qtype[qName] extends { answers: Readonly<unknown[]> } ?
Qtype[qName]['answers'][number] : never
}
type AnswerRecords<Qtype> = Partial<AnswerRecordsNoBlanks<Qtype>>
type Config<Qtype> = {
calculate(ctx: { answers: AnswerRecords<Qtype> }): void | number;
questions: Qtype;
}
/* set A config */
const QuestionSetA = {
questionA1: {
title: "Title A1",
answers: ["A1_a", "A1_b", "A1_c"],
},
questionA2: {
title: "Title A2",
answers: ["A2_a", "A2_b"],
},
} as const;
type SetAType = typeof QuestionSetA;
const config_setA: Config<SetAType> = {
calculate({ answers }) {
answers.questionA1
answers.questionA2
},
questions: QuestionSetA
}
/* set B config */
const QuestionSetB = {
questionB1: {
title: "Title B1",
answers: ["B1_a", "B1_b", "B1_c"],
},
questionB2: {
title: "Title B2",
answers: ["B2_a", "B2_b"],
},
} as const;
type SetBType = typeof QuestionSetB;
const config_setB: Config<SetBType> = {
calculate({ answers }) {
answers.questionB1
answers.questionB2
return 100;
},
questions: QuestionSetB
}
/* set C config */
//after some copy/pasting I realize it's easy to miss replacing 'B' with 'C'
//e.g. in 'type SetCType = typeof QuestionSetC' and 'questions: QuestionSetC'
//while this is still manageable, a function-call approach like in the other answer may be more reliable? We may need to check.
const QuestionSetC = {
questionC1: {
title: "Title C1",
answers: ["C1_a", "C1_b", "C1_c"],
},
} as const;
type SetCType = typeof QuestionSetC;
const config_setC: Config<SetCType> = {
calculate({ answers }) { answers; },
questions: QuestionSetC
}
/* try out calculations */
let setA_student1ans: AnswerRecords<SetAType> = { questionA1: 'A1_a' };
let setA_student2ans: AnswerRecords<SetAType> = { questionA2: 'A2_b' };
let setA_student3ans: AnswerRecords<SetAType> = { questionA1: 'A1_a', questionA2: 'A2_a' };
config_setA.calculate({ answers: setA_student1ans })
let setB_student1ans: AnswerRecords<SetBType> = { questionB1: 'B1_c' };
let setB_student2ans: AnswerRecords<SetBType> = { questionB2: 'B2_a' };
let setB_student3ans: AnswerRecords<SetBType> = { questionB1: 'B1_c', questionB2: 'B2_b' };
config_setB.calculate({ answers: setB_student1ans })
Upvotes: 2
Reputation: 23905
Edit:
As noted in the comments, the type of questionOne
is not quite right yet. Here is an edit to fix this:
function useConfig<T extends Record<string, string>>(config: Config<T>){}
type CalculateCtx<T extends Record<string, string>> = { answers: {
[K in keyof T]: T[K]
}}
type Question<V extends string> = {
title: string;
answers: V[];
}
type Config<T extends Record<string, string>> = {
calculate(ctx: CalculateCtx<T>): void;
questions: {
[K in keyof T]: Question<T[K]>
}
}
Something like this can be achieved when using the config
object in a generic function.
function useConfig<T extends Record<string, Question>>(config: Config<T>){}
We use T
to store the contents of questions
.
type Config<T extends Record<string, Question>> = {
calculate(ctx: CalculateCtx<T>): void;
questions: T;
}
Config
will have to be generic too where questions
is T
and the ctx
of calculate
is CalculateCtx<T>
.
For CalculateCtx<T>
, we use the keys of T
to construct the shape of answers
with a new Record
.
type CalculateCtx<T> = { answers: Record<keyof T, string> }
You will now get the desired error when passing a config object literal to the function.
const config = useConfig({
calculate({ answers }) {
answers.questionOne
answers.questionUnknown // Error
},
questions: {
questionOne: {
title: "Test Title",
answers: ["answer-1", "answer-2", "answer-3"],
},
},
})
Upvotes: 1