2

I am working through a TypeScript playground and got most of the way there, except it is not autocompleting/typing the options correctly:

type Definition = {
  code: number
  note: (opts: Record<string, unknown>) => string
}

const errors: Record<string, Definition> = {
  invalid_form: {
    code: 3,
    note: ({ name }) => `Form '${name}' is not valid`,
  },
  invalid_type: {
    code: 2,
    note: ({ name, type }) => `Value '${name}' is not '${type}' type`,
  },
  missing_property: {
    code: 1,
    note: ({ name }) => `Property '${name}' missing`,
  },
}

type Errors = typeof errors
type Name = keyof Errors

function findError<N extends Name>(name: N, options: Parameters<Errors[Name]['note']>[0]) {
  const handler = errors[name]
  const text = handler.note(options)
  return new Error(text)
}

findError('invalid_type', { foo: 'string' })

Notice the foo: 'string' at the bottom, it should be erroring, and when I type n in the key, it should autocomplete name since that is the defined property for invalid_type (even though name property is an unknown type). It should also autocomplete invalid_type as I type the string (which it is currently not, I'm guessing because of the Record<string, Definition> type?).

How can I accomplish this in TypeScript? I would like to avoid having to create a "type" for each note function options, that would be overkill since it is just a map of string keys to any stringable value. Because having to write a type for each note function (of which there could be dozens / a hundred) would be overkill IMO, so hoping to avoid that if possible.

I feel like somehow the options: Parameter... type must involve extends and a conditional inferred type to get at the keys of the parameters, and build a type from that somehow, but I'm having trouble figuring out how that might work. I tried this, but it didn't change anything:

function findError<
  N extends Name,
  O extends Parameters<Errors[N]['note']>[0],
>(
  name: N,
  options: {
    [P in keyof O]: O[P]
  },
) {
  const handler = errors[name]
  const text = handler?.note(options)
  return new Error(text)
}

findError('invalid_type', { foo: 'array' })

Or this:

function findError<N extends Name>(
  name: N,
  options: Parameters<Errors[N]['note']>[0] extends infer O
    ? {
        [P in keyof O]: O[P]
      }
    : never,
) {
  const handler = errors[name]
  const text = handler?.note(options)
  return new Error(text)
}

findError('invalid_type', { foo: 'array' })

Or even:

function findError<N extends Name>(
  name: N,
  options: Parameters<Errors[N]['note']>[0] extends infer O
    ? {
        [KeyType in keyof O as {} extends Record<KeyType, unknown>
          ? never
          : KeyType]: O[KeyType]
      }
    : never,
) {
  const handler = errors[name]
  const text = handler?.note(options)
  return new Error(text)
}

findError('invalid_type', { foo: 'array' })

Note: I put the Record<string, Definition> on the errors map so you get type hinting there too, when creating the definitions.

Update

I got the autocomplete to work:

function findError<N extends Name>(
  name: N,
  options: Parameters<Errors[N]['note']>[0],
) {
  const handler = errors[name]
  const text = handler?.note(options)
  return new Error(text)
}

findError('invalid_form', { name: 'any', type: 'asdf' })

But the handler?.note(options) is now giving me:

Argument of type '{ name: any; } | { name: any; type: any; } | { name: any; another: any; }' is not assignable to parameter of type '{ name: any; } & { name: any; type: any; } & { name: any; another: any; }'.
  Type '{ name: any; }' is not assignable to type '{ name: any; } & { name: any; type: any; } & { name: any; another: any; }'.
    Property 'type' is missing in type '{ name: any; }' but required in type '{ name: any; type: any; }'.ts(2345)
Lance
  • 75,200
  • 93
  • 289
  • 503

1 Answers1

2

As for this solution:

function findError<N extends Name>(
  name: N,
  options: Parameters<Errors[N]['note']>[0],
) {
  const handler = errors[name]
  const text = handler?.note(options)
  return new Error(text)
}

findError('invalid_form', { name: 'any', type: 'asdf' })

You are getting this error:

Argument of type '{ name: any; } | { name: any; type: any; } | { name: any; another: any; }' is not assignable to parameter of type '{ name: any; } & { name: any; type: any; } & { name: any; another: any; }'.
  Type '{ name: any; }' is not assignable to type '{ name: any; } & { name: any; type: any; } & { name: any; another: any; }'.
    Property 'type' is missing in type '{ name: any; }' but required in type '{ name: any; type: any; }'.ts(2345)

Because function arguments are in contravariant position, this is why TS after inference intersects them - for safety. See this answer for more explanation.

The problem is in this line const handler = errors[name] handler has a type of a union of several function. Ask yourself - what is the safest way to call such function ? THe safest way is to provide a type which will be safe for each function or in other words intersect (merge) a type of each argument. In order to fix it , you need to end up with intersection of all functions instead of unionizing them.

COnsider this example:


type WithName = {
  name: string
}

type WithType = WithName & {
  type: string
}

type Opts = WithName | WithType

type Definition = {
  code: number
  note: (opts: Record<PropertyKey, string>) => string
}

const errors = {
  invalid_form: {
    code: 3,
    note: ({ name }: WithName) => `Form '${name}' is not valid`,
  },
  invalid_type: {
    code: 2,
    note: ({ name, type }: WithType) => `Value '${name}' is not '${type}' type`,
  },
  missing_property: {
    code: 1,
    note: ({ name }: WithName) => `Property '${name}' missing`,
  },
}

type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

type Errors = typeof errors
type Name = keyof Errors

function findError<N extends Name>(
  name: N,
  options: Parameters<Errors[N]['note']>[0],
) {
  const handler = errors[name].note
  const overloadedHandler = handler as unknown as UnionToIntersection<typeof handler>
  const text = overloadedHandler(options)
  return new Error(text)
}

findError('invalid_form', { name: 'any' })

Playground

Now, findError expects exact type of argument for invalid_form and forbids using type property

Furthermore, using Definition for all kind of note functions is not the best idea because you want to know exact type of second argument when you provide first argument to findError. Using Record<string,unknown> is vogue.

In order to infer your types it worth using satisfies with as const assertion:


type WithName = {
  name: string
}

type WithType = WithName & {
  type: string
}

type Opts = WithName | WithType

type Definition = {
  code: number
  note(opts: Record<string, unknown>): string
}

const errors = {
  invalid_form: {
    code: 3,
    note: ({ name }: WithName) => `Form '${name}' is not valid`,
  },
  invalid_type: {
    code: 2,
    note: ({ name, type }: WithType) => `Value '${name}' is not '${type}' type`,
  },
  missing_property: {
    code: 1,
    note: ({ name }: WithName) => `Property '${name}' missing`,
  },
} as const satisfies Record<string, Definition>

type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

type Errors = typeof errors
type Name = keyof Errors

function findError<N extends Name>(
  name: N,
  options: Parameters<Errors[N]['note']>[0],
) {
  const handler = errors[name].note
  const overloadedHandler = handler as unknown as UnionToIntersection<typeof handler>
  const text = overloadedHandler(options)
  return new Error(text)
}

findError('invalid_form', { name: 'any' })

Playground

Please take a look on Definition declaration:

type Definition = {
  code: number
  note(opts: Record<string, unknown>): string
}

I made note as a method instead of arrow function. I wanted to loose strictness, because methods are bivariant (less safe). Try to convert it to arrow function and you will get an error near satisfies.

You can find more information and explanation in my blog