The part where (typeof path === "function") does not suffice to narrow path from T to PathConstructorFunction is a longstanding issue in TypeScript where values of generic types are not narrowed due to control flow analysis; see microsoft/TypeScript#13995. This will be, at long last, resolved when TypeScript 4.3 is released, due to the work done in microsoft/TypeScript#43183.
For TypeScript 4.2 and below, the best you can do is widen the value from a generic to a specific type... from T to ApiPath, like this:
const processApiPath = <T extends ApiPath>(
_path: T, // <-- rename out of the way
arg?: T extends PathConstructorFunction ? Parameters<T>[0] : never
) => {
const path: ApiPath = _path
if (typeof path === "string") {
return path
} else if (typeof path === "function") {
return path(arg)
} else {
throw new Error("Invalid path")
}
}
Then the narrowing works as expected. In what follows I will be using this approach to keep it compatible with TS4.2.
As I mentioned in the comments, you don't want (arg: object) => string because function arguments are checked contravariantly instead of covariantly. If T extends (arg: object) => string, it means that T's argument can be wider than object, not narrower. See the documentation for --strictFunctionTypes for more information.
To properly express that PathConstructorFunction should mean "I want (arg: XXX) => string where XXX is some object type", you need PathConstructorFunction to be generic in that XXX. This means that a lot of your code must sprout an extra type parameter:
type Path = string
type PathConstructorFunction<A extends object> = ((arg: A) => string)
export type ApiPath<A extends object> = Path | PathConstructorFunction<A>
export const processApiPath = <T extends ApiPath<A>, A extends object>(
_path: T,
arg?: T extends PathConstructorFunction<A> ? A : never
) => {
const path: ApiPath<A> = _path;
if (typeof path === "string") {
return path
} else if ((typeof path === "function") && arg) {
return path(arg)
} else {
throw new Error("Invalid path")
}
}
Now everything works as expected. Note that you also have to check arg when calling path(arg), because you made arg optional. (There might be better ways to deal with this than making arg optional, but I'm going to limit the scope of this question to what was asked, and not expand it to every possible improvement that might be made). That means you might end up calling path(undefined) and the compiler rightfully complains about it.
Anyway, let's see if it works from the call side:
const foosPath = '/foos';
const barsPath = ({ fooId }: { fooId: number }) => `/foos/${fooId}/bars`
console.log(processApiPath(foosPath)) // okay
console.log(processApiPath(barsPath, { fooId: 123 })) // okay
processApiPath(foosPath, { fooId: 123 }); // error!
// Argument of type '{ fooId: number; }' is
// not assignable to parameter of type 'undefined'
processApiPath(barsPath, { fooId: "abc" }); // error!
// Types of property 'fooId' are incompatible.
Looks good.
Playground link to code
pathis a function, like this, but having yourobjectparameter type is a problem, since function parameters are contravariant, and so(p: {x: number})=>voiddoes not extend(p: object)=>void. Between that and your odd choice to pass123in for something looking for astring, I'm not sure this is enough of a minimal reproducible example to be answered properly. Could you address or remove these?fooIdwas a typo and has been updated to be a number. Your observation around theobjectparameter being the problem is spot on. I did some reading on contravariance and I can see why my condition is not evaluated the way I intended. However, I still don't know how to properly express what I'm looking for. How could I check forTbeing a function that receives anobjectparameter?