I am currently struggling with some sort of type inference.
Current State
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>;
}
Goal State
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.
CodePudding user response:
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"],
},
},
})
CodePudding user response:
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 })