I've been scratching my head over this for a while now, so I've decided to ask the question here on Stackoverflow in the hopes that somebody will be able to help me. This is a simplified code snippet version of my problem: TypeScript Playground
type options = "bar" | "foo";
interface barFunctions {
barFunction: () => void;
}
interface fooFunctions {
fooFunction: () => void;
}
interface returnFunctions {
bar: barFunctions,
foo: fooFunctions
}
const example = <T extends options>(options: options): returnFunctions[T] => {
if (options === "bar") {
return {
barFunction() {
console.log("It's the bar function");
}
// I don't expect an error here, the options parameter is set to "bar", so there is no need for a "fooFunction..."
}
}
if (options === "foo") {
return {
fooFunction() {
console.log("It's the foo function");
}
// I don't expect an error here, the options parameter is set to "foo", so there is no need for a "barFunction..."
}
}
throw new Error(`Expected either foo or bar but got ${options}`)
}
To explain:
I want autocompletion on the example
function once it executes with a certain option
parameter.
So if I type example("foo").<autocompletion expects fooFunctions interface>
. So it would show me example("foo").fooFunction()
is the only option, because the argument to the first function is "foo".
And if I type example("bar").<autocompletion expects barFunctions interface>
. So it would show me example("bar").barFunction()
is the only option, because the argument to the first function is "bar".
However the problem now is that both return objects expect the other function to be there, even though I don't want that...
Is there any Typescript expert out there that could help me?
CodePudding user response:
There are multiple ways to solve this. Most are not type-safe (e.g. overloads, or using type assertions) as they provide no way for the compiler to validate the logic in the function implementation.
Your generic approach is not far off. First thing to fix is that your generic type T
is not used to type the parameter. Let's give the parameter option
the type T
.
const example = <T extends options>(option: T): returnFunctions[T] => {
return {
get bar() {
return {
barFunction() {
console.log("It's the bar function");
},
};
},
get foo() {
return {
fooFunction() {
console.log("It's the foo function");
}
}
}
}[option]
};
Now to get the type-safety we want, we have to convert our function implementation to this map-shaped structure. Each key is a getter
to defer the valuation of each path. We do this to avoid having to compute all paths every time the function is called. This may not matter to you if computing those is not expensive.
TypeScript can validate that this map-shaped structure conforms to the type returnFunctions[T]
.
CodePudding user response:
You need to actually use the type parameter as the type for options
:
const example = <T extends options>(options: T): returnFunctions[T] => {
///...
}
example("bar").barFunction()
example("foo").fooFunction()
Now with this version you still have errors in the implementation because TypeScript can't really follow that if you narrow option
the type of the return will also be narrowed.
You can solve this with a type assertion:
const example = <T extends options>(options: T): returnFunctions[T] => {
if (options === "bar") {
return {
barFunction() {
console.log("It's the bar function");
}
} as returnFunctions[T]
}
if (options === "foo") {
return {
fooFunction() {
console.log("It's the foo function");
}
} as returnFunctions[T]
}
throw new Error(`Expected either foo or bar but got ${options}`)
}
Or you can instead use overloads, with the public signature being generic and the implementation signature not being generic. Although this doesn't add safety (you are responsible for making sure the parameter and return type agree, it does make the code look better IMO)
function example<T extends Options>(options: T): ReturnFunctions[T]
function example(options: Options): ReturnFunctions[Options] {
if (options === "bar") {
return {
barFunction() {
console.log("It's the bar function");
}
}
}
if (options === "foo") {
return {
fooFunction() {
console.log("It's the foo function");
}
}
}
throw new Error(`Expected either foo or bar but got ${options}`)
}
Also I would derive options from the ReturnFunctions
interface and use upper case for type (but those are just nit picks) Playground Link
CodePudding user response:
You can use strategy pattern here:
type options = "bar" | "foo";
interface barFunctions {
barFunction: () => void;
}
interface fooFunctions {
fooFunction: () => void;
}
interface returnFunctions {
bar: barFunctions,
foo: fooFunctions
}
const fnStrategy: returnFunctions = {
bar: {
barFunction() {
console.log("It's the bar function");
}
},
foo: {
fooFunction() {
console.log("It's the bar function");
}
}
}
const example = <T extends options>(options: T): returnFunctions[T] => fnStrategy[options]
const result = example('foo') // fooFUnctions
CodePudding user response:
You can use overloading to achieve your goal:
function example(options: 'foo'): fooFunctions;
function example(options: 'bar'): barFunctions;
function example(options: 'foo' | 'bar') : fooFunctions | barFunctions {
// The code
}
example('foo').fooFunction(); // Autocompletion works as expected
example('bar').barFunction(); // Autocompletion works as expected
exmaple('other'); // Fails because it is not an accepted
Alternatively, you can use the keyof
to maintain a syntax similar to yours:
interface returnFunctions {
bar: barFunctions,
foo: fooFunctions
}
const example = <T extends keyof returnFunctions>(options: T): returnFunctions[T] => {
// The code
}
This also makes autocompletion to work as you expect.