Búsqueda de sitios web

javascript - Tipo de Typescript para dos tipos con las mismas claves y valores similares


Tengo una clase que toma un objeto como argumento del constructor, y quiero imponer un método en la clase para aceptar un objeto muy similar. Los objetos tienen claves arbitrarias.

Por ejemplo, construyendo con este objeto:

{
  foo: { type: 'A' },
  bar: { type: 'B'}
}

Querré que el método solo acepte objetos de forma similar, es decir, que tengan las mismas claves y que para cada clave el tipo de valor sea compatible con el objeto inicial. Como:

{
  foo: SomeARelatedThing,
  bar: SomeBRelatedThing
}

Tengo una solución temporal en la que al menos puedo hacer cumplir las mismas claves, y luego hacer muchas comprobaciones de tipo (¡bueno de hacer de todos modos!) para asegurarme de que los valores realmente coincidan.

Aquí hay un ejemplo artificial de mi caso de uso:

type TypeName = 'A' | 'B' | 'C' // ...

class Action<K extends TypeName> { 
  type: K
  constructor(type: K) { this.type = type } 
}
type AnyAction = Action<'A'> | Action<'B'> | Action<'C'> // ...

type AProp = { type: 'A' }
type BProp = { type: 'B' }
type CProp = { type: 'C' }
type AnyProp = AProp | BProp | CProp // ...

type PropMap<K extends string> = Record<K, AnyProp>
type ActionMap<K extends string> = Record<K, AnyAction>

class Thing<K extends string> {
  props: PropMap<K>
  constructor(props: PropMap<K>) { this.props = props }

  myMethod<>(actions: ActionMap<K>) { /* ... */ }
}

// type = Thing<'foo' | 'bar'>
const thing = new Thing({
  foo: { type: 'A' },
  bar: { type: 'B'}
})

// the keys are enforced, but how can the values of foo and bar be enforced, too
thing.myMethod({
  foo: new Action('A'),
  bar: new Action('B'),
}) 

Creo que querría algo más parecido a un tipo igual a Thing<{foo: 'A', bar: 'B'}>, pero no sé cómo calcular condicionalmente eso a partir de una entrada similar a PropMap para el constructor de Thing, o incluso si lo supiera, ¿cómo calcularía el tipo correcto similar a ActionMap?

MyMethod en realidad acepta un Partial<ActionMap<K>> pero no creo que eso deba importar para lo que estoy preguntando.

Respuesta #1

Creo que lo tengo. Necesitas usar tipos mapeados.

class Thing<K extends string, P extends PropMap<K>> {
  props: P
  constructor(props: P) { this.props = props }

  myMethod(actions: {[Property in keyof P]: Action<P[Property]["type"]>}) { return actions }
}

const thing = new Thing({
  foo: { type: 'A' },
  bar: { type: 'B'}
})

// the keys are enforced, as well as the corresponding Action types
thing.myMethod({
  foo: new Action('A'),
  bar: new Action('B'),
}) 

Ten en cuenta que necesitas agregar el genérico P a tu clase Thing, de lo contrario TypeScript no tiene forma de inferir información más detallada cuando instancias Thing más adelante. Básicamente, necesitas establecer P para que sea el mismo tipo genérico de manera consistente dentro de Thing, de lo contrario no hay manera de diferenciarlo del tipo PropMap del que se extiende.

Luego, la magia ocurre en actions: {[Property in keyof P]: Action<P[Property]["type"]>}. Vamos a desglosarlo:

  1. [Property in keyof P]: firma de índice de tipo mapeado. Esto es lo que nos permite acceder a las claves específicas en P, por ejemplo, foo, bar, etc.
  2. Action<...> establecerá el valor correspondiente a cada clave que estamos mapeando en (1.) arriba a algún valor, que queremos que sea un Action, como Action<'A'>, etc. PERO queremos que la acción se derive del valor original de P, así que...
  3. P[Property]["type"] nos permite acceder al valor del par clave/valor type del tipo original de P. Dado que Property varía (se asigna de un tipo a otro), entonces, por ejemplo, se convierte en foo: P["foo"]["type"] que es 'A', bar: P["bar"]["type"] que es 'B', etc.

Parque infantil