node.js - Typsäkerhet vid skapande av ett objekt


Jag behöver lite hjälp med TypeScript för att uppnå typsäkerhet för ett objekt medan jag bygger det. Jag har en funktion foo som tar emot ett objekt med egenskaper. Låt oss säga att det har två egenskaper, foo och baz. Varje egenskap kommer att ha samma konfigurationsobjekt. Konfigurationsobjektet har följande form:

type ConfigObject = {
  action: (...args: any[]) => Promise<any>;
  dependencies: string[];
}

Typerna är ganska breda, men detta är bara för demonstrationsändamål. Jag vill att typesystemet ska härleda argumenten för min actionsfunktion när ett konfigurationsobjekt specificerar ett beroende. Istället för any, bör det härleda ett objekt med egenskaper som motsvarar min beroendearray (som endast kommer att ha typesäkerhet för att introducera egenskaper från mitt ursprungliga rotobjekt) och med värden som är returtypen för varje av dessa beroenden, respektive.

Till exempel:

const obj = {
  foo: {
    action: () => {
      return [1, 2, 3];
    },
  },
  baz: {
    action: ({ foo }) => {
      const fooResult = foo; // should be inferred as [1, 2, 3]
      fooResult.forEach((value) => console.log(value)); // 1, 2, and 3
    },
  },
};

Observera att beroende-arrayen kan vara valfri, och vi vill även upprätthålla typ-säkerhet för den. Hur kan jag uppnå denna nivå av typ-säkerhet och härledning för mitt objekt? All hjälp skulle uppskattas!

Detta är mitt svåraste ärliga misslyckade försök att uppnå det, men jag slutar med mina argument som alla andra

type DependencyMap<T extends Record<string, ConfigObject<any, any>>> = {
  [K in keyof T]: ReturnType<T[K]['action']> extends Promise<infer R> ? R : never;
};

type ResolveDependencies<
  T extends Record<string, ConfigObject<any, any>>,
  D extends readonly (keyof T)[]
> = {
  [K in D[number]]: DependencyMap<T>[K];
};

type ConfigObject<T extends Record<string, ConfigObject<any, any>>, D extends readonly (keyof T)[] = [], R = any> = {
  action: (deps: ResolveDependencies<T, D>) => Promise<R>;
  dependencies?: D;
};

type ConfiguredObject<T extends Record<string, ConfigObject<any, any>>> = T;

function createObject<const T extends ConfiguredObject<any>>(arg: T): T {
  return arg;
}

const obj = createObject({
  foo: {
    action: () => {
      return Promise.resolve([1, 2, 3]);
    },
  },
  baz: {
    action: ({ foo }) => {
      const fooResult = foo; // should be infered as [1,2,3] or at least number[]
      fooResult.forEach((value) => console.log(value)); // 1, 2, 3
      return Promise.resolve();
    },
    dependencies: ['foo'],
  },
});

Svar #1

För att ditt tillvägagångssätt ska fungera skulle kompilatorn behöva ta ett enda objekt med en mängd sammankopplade egenskaper, och från det objektet härleda en generell typargument T samtidigt som den kontextuellt härleder en mängd callback-parameter-typer från andra egenskaper. Även om den här sortens saker kan vara möjliga i specifika, begränsade fall, är det ohanterligt för det generella fallet som ditt, åtminstone med TypeScripts nuvarande tillvägagångssätt för typinferens.

Kanske skulle en fullständig föreningsalgoritm kunna hantera det, som beskrivs i microsoft/TypeScript#30134, men TypeScript använder inte en sådan algoritm. Enligt chefsarkitekten för språket i microsoft/TypeScript#17520 har den nuvarande algoritmen "den tydliga fördelen att kunna göra partiella härledningar i ofullständig kod vilket är enormt fördelaktigt för uttalskomplettering i IDE:er." Utan fullständig förening kommer TypeScript oundvikligen att misslyckas med att härleda typer i vissa fall, och ditt fall är tillräckligt komplext för att det kanske alltid ligger utom räckhåll. Se microsoft/TypeScript#47599 för ett relaterat problem.


Så vad kan vi göra istället för att försöka få TypeScript att härleda från ett enda komplicerat objekt? Ett tillvägagångssätt är att omstrukturera till att använda ett flytande byggmönster, där du bygger upp ditt objekt i steg via kedjade metodanrop. Varje metodanrop ber kompilatorn att härleda bara en egenskap, och den beror endast på de tidigare härledda typerna. (Observera att detta endast fungerar om dina konfigurationsberoenden bildar ett träd; om de är cirkulära, som foo beror på bar och bar beror på foo, så är det inte möjligt att göra det direkt på detta sätt... men förmodligen händer detta inte.) Detta är mycket mer lämpat för TypeScripts härledningsalgoritm. Och i slutet anropar du den slutliga "build"-metoden som producerar det enda objektvärde du ville ha.

Det kan se ut så här:

interface ConfigBuilder<T extends object, TK extends Record<keyof T, keyof T>> {

  add<P extends PropertyKey, U, K extends keyof T = never>(
    propName: P,
    action: (...args: { [Q in K]: Awaited<T[Q]> }[]) => U,
    dependencies?: K[]
  ): ConfigBuilder<T & { [Q in P]: U }, TK & { [Q in P]: K }>;

  build(): { [K in keyof T]: {
    action: (...args: { [P in TK[K]]: Awaited<T[P]> }[]) => T[K],
    dependencies: K[]
  } }
}

const configBuilder = {
  config: {},
  add(p, a, d = []) {
    return {
      ...this,
      config: {
        ...(this as any).config,
        [p]: { action: a, depenencies: d }
      }
    } as any;
  },
  build() {
    return (this as any).config;
  }
} as ConfigBuilder<{}, {}>;

En ConfigBuilder<T, TK> representerar en byggare som för närvarande håller på ett delvis byggt config-objekt, där T representerar typen på utdata för varje action-metod, och där TK representerar de nycklar som varje egenskap beror på. Jag har skrivit configBuilder som en ConfigBuilder<{}, {}>, vilket är en byggare för ett tomt objekt... notera att varje gång du anropar add() får du en ny byggare, så att du kan återanvända mellanliggande objekt om du behöver.

Låt oss se det i aktion:

const config = configBuilder
  .add("foo", () => [1, 2, 3] as const)
  .add("baz", ({ foo }) => {
    const fooResult = foo; 
    //    ^? const fooResult: readonly [1, 2, 3]
    fooResult.forEach((value) => console.log(value)); 
  }, ["foo"])
  .build();

/* const config: {
    foo: {
        action: (...args: {}[]) => readonly [1, 2, 3];
        dependencies: "foo"[];
    };
    baz: {
        action: (...args: {
            foo: readonly [1, 2, 3];
        }[]) => void;
        dependencies: "baz"[];
    };
} */

Ser bra ut. Du kan se att kompilatorn kan härleda typen av action-callbackparametern vid varje steg, och att utgångstypen är som förväntat. Och det fungerar:

config.baz.action({ foo: [1, 2, 3] }); // logs 1, 2, 3

Bara för att vara tydlig, låt oss titta på vad som händer i varje steg:

const cb1 = configBuilder.add("foo", () => [1, 2, 3] as const);
// const cb1: ConfigBuilder<
//   { foo: readonly [1, 2, 3]; }, 
//   { foo: never; }
// >

const cb2 = cb1.add("baz", ({ foo }) => {
  const fooResult = foo; // should be inferred as [1, 2, 3]
  fooResult.forEach((value) => console.log(value)); // 1, 2, and 3
}, ["foo"]);
// const cb2: ConfigBuilder<
//    { foo: readonly [1, 2, 3]; } & { baz: void; }, 
//    { foo: never; } & { baz: "foo"; }
// >;
    
const cfg = cb2.build();
// const cfg: typeof config // <-- same as before

Det första anropet till add() producerar en ConfigBuilder<{ foo: readonly [1, 2, 3]; }, { foo: never; }>, vilket betyder att det finns ett objekt vars foo-åtgärd producerar en readonly [1, 2, 3], och som inte har några beroenden. Det andra anropet till add() producerar motsvarande ConfigBuilder<{ foo: readonly [1, 2, 3]; baz: void; }, { foo: never; baz: "foo"; }>, vilket är samma som tidigare förutom att det också finns en baz-egenskap vars åtgärd inte returnerar något användbart, och som är beroende av foo-egenskapen. Och slutligen, när du anropar build() får du det slutliga konfigurationsobjektet.

Lekplatslänk till kod

Svar #2

Det du försöker göra är en extremt svår uppgift, som kanske inte ens är möjlig att genomföra med tanke på dina krav.

I grund och botten återuppfinner du ett beroendeinjektionsmönster men på nivån för ett enskilt objekt och beroenden mellan dess egenskaper istället för ett standard sätt att registrera klasser i en DI-container, vilket du mycket enkelt skulle kunna göra med hjälp av bibliotek som TypeDI, InversifyJS osv.

Exempel som använder TypeDI som bara fungerar utan behov av komplexa anpassade typer:

import 'reflect-metadata';
import { Container, Service } from 'typedi';

type ConfigObject = {
  action: (...args: any[]) => Promise<any>;
}

@Service() class Foo implements ConfigObject {
  action() {
    return Promise.resolve([1, 2, 3] as const);
  }
}

@Service() class Bar implements ConfigObject {
  constructor(private foo: Foo) {} // this is how you declare dependency
  async action() {
    // YES, fooResult is inferred as [1,2,3]
    // OR as number[] if you remove `as const` from foo's return
    const fooResult = await this.foo.action();
    fooResult.forEach((value) => console.log(value)); // YES, prints 1, 2, 3
    return Promise.resolve();
  }
}

const bar = Container.get(Bar);
bar.action();

Jag vet att du ställer en mycket specifik fråga om hur man får din lösning att fungera, men din lösning försöker lösa ett problem och om det problemet är att kombinera flera objekt med action()-metoder och med ömsesidiga beroenden, då kanske lösningen att bygga ett anpassat beroendeinjektionssystem på en abstraktionsnivå som jag inte har sett tidigare, inte är den mest optimala lösningen för det.

Jag skulle seriöst överväga att använda ett riktigt DI-system och om någon kan lösa problemet som du påförde dig själv genom att besluta att uppfinna ett egenskapsnivå beroendeinjektionssystem för ett enda objekt, skulle jag gärna vilja se lösningen.