0

The function processApiPath accepts either a string, in which case it returns that same string, or a function which is then called with the second parameter args. Playground here.

type Path = string
type PathConstructorFunction = ((args: object) => string)

export type ApiPath = Path | PathConstructorFunction

export const processApiPath = <T extends ApiPath>(
    path: T,
    args?: T extends PathConstructorFunction ? Parameters<T>[0] : never
) => {
    if (typeof path === "string") {
        return path
    } else if (typeof path === "function") {
        return path(args)
    } else {
        throw new Error("Invalid path")
    }
}

const foosPath = '/foos'

const barsPath = ({ fooId }: { fooId: number}) => `/foos/${fooId}/bars`

console.log(processApiPath(foosPath)) // '/foos'
console.log(processApiPath(barsPath, {fooId: 123})) // '/foos/123/bars'
  1. TypeScript is indicating that path(args) (inside the processApiPath function) is not callable despite me explicitly checking that it's type is a function. Why is this?

  2. I'm trying to indicate that args will have the type of the first parameter of T but only when T is a function. This isn't working and I believe it's because T has not yet been assigned a type when the conditional statement is evaluated. Is that accurate and if so, what would be the proper way to express this?

2
  • If you use TS4.3 beta the compiler will understand that path is a function, like this, but having your object parameter type is a problem, since function parameters are contravariant, and so (p: {x: number})=>void does not extend (p: object)=>void. Between that and your odd choice to pass 123 in for something looking for a string, I'm not sure this is enough of a minimal reproducible example to be answered properly. Could you address or remove these? Commented Apr 12, 2021 at 16:23
  • Thanks for your help! The type of fooId was a typo and has been updated to be a number. Your observation around the object parameter 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 for T being a function that receives an object parameter? Commented Apr 12, 2021 at 20:41

3 Answers 3

1

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

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

1 Comment

Can't thank you enough for the answer and explanation! Your usage of wider and narrower in terms of the function arguments really helped. I'll take a look at what else I can do instead of making arg optional.
1

By using extends on an union type you're limiting what can be done with type guards. It seems silly that something extending string would have typeof === 'function' be true, but I guess it's possible. However, it's not possible for just a string...

If you change the generic signature so that the generic parameter T is ApiPath then everything should work. Yes, it is strange to set a generic type to an explicit type, but it allows you to do the conditional type for args.

export const processApiPath = <T = ApiPath>( // <--- wierd, right?
  path: T,
  args?: T extends PathConstructorFunction ? Parameters<T>[0] : never
) => {
  if (typeof path === "string") {
    return path;
  } else if (typeof path === "function") {
    return path(args);
  } else {
    throw new Error("Invalid path");
  }
};

1 Comment

Thanks for your help! That fixed the first issue and now path is interpreted correctly as a function but I'm still getting errors around the args type.
1

I played around with this and got something to compile using overloads. The main issue I encountered was actually due to the object type in PathConstructorFunction. In general, the object type is not great and should be avoided.

Here's what I came up with:

type Path = string
// I use Record<PropertyKey, unknown> rather than object here and also make
// it generic
type PathConstructorFunction<T extends Record<PropertyKey, unknown>> = (args: T) => string

export function processApiPath(path: Path): string
export function processApiPath<T  extends Record<PropertyKey, unknown>>(
  path: PathConstructorFunction<T>,
  args: T
): string
export function processApiPath<T extends Record<PropertyKey, unknown>>(
  path: Path | PathConstructorFunction<T>,
  args?: T
): string {
  if (typeof path === "string") {
      return path
  }

  if (typeof path === "function" && args) {
      return path(args)
  }

  throw new Error("When passing a PathConstructorFunction, you must also pass the arguments to pass to it as the 2nd argument")
}

const foosPath = '/foos'

const barsPath: PathConstructorFunction<{ fooId: string }> = ({ fooId }) => `/foos/${fooId}/bars`

console.log(processApiPath(foosPath)) // '/foos'
console.log(processApiPath(barsPath, { fooId: "123" })) // '/foos/123/bars'

2 Comments

Thanks for taking a look! The TS errors are gone, however, in my testing I lose some of the type safety. Specifically, I can pass an object with an invalid property (e.g. { batId: "hello" }) as the args without an error.
Thanks for pointing it out - I've improved the types a bit now. It still won't prevent extra properties in the function argument, but it will ensure that the required ones are there.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.