typescript - Hvordan sterkt typisere Oak-kontekstens state-objekt i deno?


Jeg vil sterkt typekaste context.state-objektet som gis av oak i deno. Jeg har allerede sett tilnærminger til hvordan dette muligens kunne fungere (f.eks. Deno oak v10.5.1 context.cookies aldri blir satt), men har ennå ikke klart å implementere det selv i min egen kode.

Målet mitt er å få tilgang til den sterkt typede context.state i hver av mine mellomlag.

Dette er grensesnittet for context.state:

interface State {
    userID: number;
    sessionID: number;
}

Dette ville være en av mine mellomvarefunksjoner for å sette context.state, der jeg prøvde å få tilgang til egenskapene til context state:

const contextMiddleware = async (context: Context, next: () => Promise<unknown>): Promise<void> => {
    context.state.userID = 123;
    context.state.sessionID = 456;
    await next();
    delete context.state.userID;
    delete context.state.sessionID;
}

Mitt problem er at i contextMiddleware-funksjonen er disse to egenskapene av typen any og ikke den forventede typen number. Videre gjenkjenner ikke intellisense disse to egenskapene for autoutfylling.

Jeg har sett at løsningen kan være å sende IState-grensesnittet som generiske til Application- og Router-objektet som kaller midlertidig programvare, og deretter sette contextMiddleware-funksjonen til typen RouterMiddleware eller Middleware med de samme generikkene fra oak-importen mod.ts.

Dette kan se slik ut, men det fungerer ikke for øyeblikket:

import {
  Application,
  type Context,
  type Middleware,
  Router,
} from "https://deno.land/x/[email /mod.ts";

interface State {
    userID: number;
    sessionID: number;
}

const contextMiddleware: Middleware<State, Context<State, State>> = async (context: Context, next: () => Promise<unknown>): Promise<void> => {
    context.state.userID = 123;
    context.state.sessionID= 456;
    await next();
    delete context.state.userID;
    delete context.state.sessionID;
}

const defaultRouter = new Router<State>();

defaultRouter
  .use(contextMiddleware)
  .get("/(.*)", (context: Context) => {
    context.response.status = 404;
    context.response.body = "Endpoint not available!";
  });

const app = new Application<State>();

app.use(defaultRouter.routes(), defaultRouter.allowedMethods());

app.addEventListener("listen", ({ hostname, port, secure }) => {
  console.log(
    `Listening on ${secure ? "https://" : "http://"}${
      hostname || "localhost"
    }:${port}`,
  );
});

await app.listen({ port: 8080 });

Hva går jeg glipp av her?

Takk for hjelpen på forhånd!

Svar #1

På grunn av hvor mange av Oaks typer som bruker generiske typer (ofte med standard typeparametere), og spesielt måten de bruker disse typene til å lage nye typer på (i noen tilfeller ikke tillater deg å oppgi typer for bruk i feltene som har standardverdier og i stedet bruker standardverdiene), kan det å sterkt tipe Oak-mellomvare være svært komplisert — spesielt hvis hovedmålet ditt er streng typesikkerhet.

Merk at Oak er et praktisk API for mellomvare-orienterte webservere. Hovedformålet er å håndtere standardkoden og hjelpe deg med å fokusere på applikasjonskoden din, og — for å være rettferdig — har det gjort noen fantastiske kompromisser som gir veldig gode typer i prosessen. Imidlertid ser standardene som brukes i Oaks typesystem ut til å være vinklet mot bekvemmelighet fremfor sikkerhet på noen steder for å oppnå det, så det er viktig å ha det i bakhodet.

Generics kan noen ganger føles krevende, og en potensielt vanskelig del ved dette er at det ikke bare finnes én «context»-type i Oak (det avhenger av hvor context brukes), og — ikke-trivielt — det finnes ikke bare én «state»-type (Oak lar deg potensielt opprette unik state-data for hver forespørsels-/respons-syklus, noe som gir deg valg for strategien den bruker til å initialisere den — mer om dette nedenfor).

La oss starte fra bunnen av de avhengige typene som er involvert og jobbe oss oppover for å få en fullstendig forståelse. Vi begynner med app:

Typen på Application ser slik ut:

class Application<AS extends State = Record<string, any>> extends EventTarget {
  constructor(options?: ApplicationOptions<AS, ServerRequest>);
  // --- snip ---
  state: AS;
  // --- snip ---
  use<S extends State = AS>(
    middleware: Middleware<S, Context<S, AS>>,
    ...middlewares: Middleware<S, Context<S, AS>>[]
  ): Application<S extends AS ? S : (S & AS)>;
  use<S extends State = AS>(
    ...middleware: Middleware<S, Context<S, AS>>[]
  ): Application<S extends AS ? S : (S & AS)>;
  // --- snip ---
}

Typeparameteren AS har en begrensning av en typealias State som ser slik ut:

type State = Record<string | number | symbol, any>;

(som i praksis bare er «et hvilket som helst vanlig objekt»). La oss nå se på alternativene som brukes for å lage appen: ApplicationOptions, som ser slik ut:

interface ApplicationOptions <S, R extends ServerRequest> {
  contextState?:
    | "clone"
    | "prototype"
    | "alias"
    | "empty";
  keys?: KeyStack | Key[];
  logErrors?: boolean;
  proxy?: boolean;
  serverConstructor?: ServerConstructor<R>;
  state?: S;
}

Jeg kommer til å hoppe over å vise de irrelevante typene: ServerConstructor og KeyStack (som Oaks hovedmodul ikke engang eksporterer, så du må finne typen selv (dårlig!)).

Valget for å definere strategien for hvordan kontekstillstanden opprettes er ApplicationOptions#contextState, og den innebygde dokumentasjonen sier dette om det (også beskrevet med litt annet språk her):

Bestem hvordan statet fra applikasjonen skal brukes når man oppretter en ny kontekst. En verdi av "clone" vil sette statet som en kopi av app-statet. Alle ikke-klonerbare eller ikke-opplistbare egenskaper vil ikke bli kopiert. En verdi av "prototype" betyr at applikasjonens state vil bli brukt som prototypen til kontekstens state, noe som betyr at grunnleggende egenskaper på kontekstens state ikke vil bli reflektert i applikasjonens state. En verdi av "alias" betyr at applikasjonens .state og kontekstens .state vil være en referanse til det samme objektet. En verdi av "empty" vil initialisere kontekstens .state med et tomt objekt.

Standardverdien er "clone".

Vi kommer til å la dette være udefinert (som bruker standardverdien) når vi instansierer appen. Diskusjon av disse algoritmene er utenfor spørsmålets omfang, men det er viktig at du er klar over hvordan dette påvirker oppførselen til koden din fordi hver innstilling resulterer i et forskjellig tilstandsobjekt for konteksten. For å se på kilden for hvordan denne verdien blir opprettet, start her.

Fordi du ikke har noen applikasjonsnivå-tilstand, ser det slik ut å lage appen (med streng typesikkerhet):

// This means "an object with no property names and no values" (e.g. `{}`)
type EmptyObject = Record<never, never>;
type AppState = EmptyObject;

const app = new Application<AppState>({ state: {} });

Merk, du kan også instansiere det på denne måten:

const app = new Application<AppState>();

og Oak vil lage det tomme objektet for deg, men jeg foretrekker at koden min er eksplisitt.

Hvis du refererer ovenfor til standardtypen som Oak bruker for den generiske typeparameteren AS (i tilfelle du ikke oppgir en), vil du se at det er Record<string, any>. Dette valget er ikke veldig typesikkert, men det er der for å gjøre bruk av ukjent eller dynamisk tilstandsdata mer praktisk. Typesikkerhet og bekvemmelighet er ikke alltid i strid med hverandre, men det er ofte tilfelle.

Det ovenstående er viktig for alt som følger: det betyr at i hver ruter og mellomvare bør typen AppState brukes i stedet for den generiske typeparameteren for applikasjonstilstand (for hvilken Oak bruker parameterens navn AS): ellers leverer Oak standardverdien som er mindre sikker.

La oss nå se på statstypen som du planlegger å bruke i hver av dine forespørsel-respons-sykluser (som Oak kaller konteksten):

type ContextState = {
  sessionID: number;
  userID: number;
};

Du kan legge merke til at jeg har gitt staten typen et nytt navn i eksemplet ditt. Du er selvfølgelig velkommen til å navngi programmets variabler og typer akkurat som du vil, men jeg vil oppmuntre deg til å ikke prefiksere hver type med I eller T. Du har kanskje sett dette i andres kode, men etter min mening er det bare støy: det er som å prefiksere hver variabel i programmet ditt med v (f.eks. vDate, vName, vAmount, osv.): det er rett og slett ikke nødvendig. I stedet oppmuntrer jeg deg til å følge den offisielle TypeScript-konvensjonen om å bruke meningsfulle navn i PascalCase.

La oss nå se på Oaks Context. Det ser slik ut:

class Context<S extends AS = State, AS extends State = Record<string, any>> {
  constructor(
    app: Application<AS>,
    serverRequest: ServerRequest,
    state: S,
    secure?,
  );
  // --- snip ---
  app: Application<AS>; // { state: AS }
  // --- snip ---
  state: S;
  // --- snip ---
}

Som du kan se, er ikke konteksttilstandstypen og applikasjonstilstandstypen nødvendigvis den samme.

og, med mindre du bestemmer noe annet, setter Oak dem på den måten (kanskje usikkert) (eller enda mindre sikkert, som Record<string, any>).

La oss også se på den andre konteksttypen som Oak tilbyr: RouterContext. Denne konteksttypen brukes innen rutere og er en smalere type som inneholder tilleggsinformasjon angående ruteinformasjonen: vei, parametre, osv. Den ser slik ut:

interface RouterContext <
  R extends string,
  P extends RouteParams<R> = RouteParams<R>,
  S extends State = Record<string, any>,
> extends Context<S> {
  captures: string[];
  matched?: Layer<R, P, S>[];
  params: P;
  routeName?: string;
  router: Router;
  routerPath?: string;
}

Jeg vil ikke diskutere RouteParams-typen her: det er en noe kompleks, rekursiv typehjelper som forsøker å bygge et typesikkert ruteparameterobjekt fra rutestrengelitteralparameteren R.

Som du kan se ovenfor, utvider denne typen Context-typen (extends Context<S>), men den gir bare kontekstens statustype (S) til den, og lar applikasjonsstatustypen (AS) være udefinert, noe som resulterer i at standardtypen brukes (Record<string, any>). Dette er det første eksemplet som vises her hvor Oak velger å ikke gi deg en måte å lage typesikker kode på. Men vi kan lage vår egen versjon som gjør det.

⚠️ Viktig: Layer-klassen blir ikke eksportert fra modulen der den er definert i Oak, selv om den brukes i Oaks publikumsvendte (eksporterte) typer (igjen, veldig dårlig!). Dette gjør det umulig å bruke i vår egendefinerte sterkere konteksttype (med mindre vi manuelt gjenskaper typen), så vi må gjøre det (for en hodepine!).

import {
  type Context,
  type RouteParams,
  Router,
  type RouterContext,
  type State as AnyOakState,
} from "https://deno.land/x/[email /mod.ts";

type EmptyObject = Record<never, never>;

interface RouterContextStrongerState<
  R extends string,
  AS extends AnyOakState = EmptyObject,
  S extends AS = AS,
  P extends RouteParams<R> = RouteParams<R>,
> extends Context<S, AS> {
  captures: string[];
  matched?: Exclude<RouterContext<R, P, S>["matched"], undefined>;
  params: P;
  routeName?: string;
  router: Router;
  routerPath?: string;
}

Denne egendefinerte typen vil være viktig for å lage typer-sikre rutermellomvarefunksjoner fordi verktøyene som tilbys av Oak for å lage dem (Middleware og RouterMiddleware) lider av de samme problemene med standardtypeparametere som RouterContext-typen, og de ser slik ut:

interface Middleware<
  S extends State = Record<string, any>,
  T extends Context = Context<S>,
> {
  (context: T, next: () => Promise<unknown>): Promise<unknown> | unknown;
}

interface RouterMiddleware<
  R extends string,
  P extends RouteParams<R> = RouteParams<R>,
  S extends State = Record<string, any>,
> {
  (
    context: RouterContext<R, P, S>,
    next: () => Promise<unknown>,
  ): Promise<unknown> | unknown;
  param?: keyof P;
  router?: Router<any>;
}

Dette svaret har allerede dekket mye (og vi er ikke ferdige ennå!), så det er en god tid å oppsummere hva vi har så langt:

import {
  Application,
  type Context,
  type RouteParams,
  Router,
  type RouterContext,
  type State as AnyOakState,
} from "https://deno.land/x/[email /mod.ts";

type EmptyObject = Record<never, never>;
type AppState = EmptyObject;

const app = new Application<AppState>({ state: {} });

type ContextState = {
  userID: number;
  sessionID: number;
};

interface RouterContextStrongerState<
  R extends string,
  AS extends AnyOakState = EmptyObject,
  S extends AS = AS,
  P extends RouteParams<R> = RouteParams<R>,
> extends Context<S, AS> {
  captures: string[];
  matched?: Exclude<RouterContext<R, P, S>["matched"], undefined>;
  params: P;
  routeName?: string;
  router: Router;
  routerPath?: string;
}

Med alt dette dekket, er vi endelig i stand til å begynne å dekke svaret på ditt direkte spørsmål, som var "Hvordan sterkt typisere Oak-konteksttilstandsobjektet i Deno?"

Nå, det egentlige svaret er (og du kan finne dette frustrerende): «det avhenger av hva som skjer i dine middleware-funksjoner, og rekkefølgen de blir kalt i (koblet sammen)». Dette er fordi hver middleware kan endre tilstanden, og den nye tilstanden vil være det neste middleware mottar. Det er dette som gjør dette så komplisert, og sannsynligvis grunnen til at Oak valgte å bruke Record<string, any> som standard.

Så, la oss se på hva som skjer i eksemplet ditt og lage typer for å representere det. Du har to mellomvarefunksjoner, og de er begge på én ruter.

Den første funksjonen:

  • tilordner noen verdier til egenskaper på konteksttilstandsobjektet, deretter
  • venter på at all påfølgende matchende mellomvare skal fullføres (det er det await next() gjør), deretter
  • sletter de egenskapene fra kontekttilstandsobjektet

En sterkt typet versjon av det ville se slik ut:

Vi oppretter også en typealias NextFn for next-funksjonen slik at vi ikke trenger å hele tiden skrive hele funksjonssignaturen for annen mellomvare.

type NextFn = () => Promise<unknown>;

async function assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd(
  context: Context<Partial<ContextState>, AppState>,
  next: NextFn,
): Promise<void> {
  context.state.userID = 123;
  //            ^? number | undefined
  context.state.sessionID = 456;
  //            ^? number | undefined
  await next(); // Wait for subsequent middleware to finish
  delete context.state.userID;
  delete context.state.sessionID;
}

Legg merke til at jeg bruker typeverktøyet Partial<Type> på ContextState (som setter alle dets medlemmer som valgfrie). Dette er av to grunner:

  1. Disse egenskapene finnes ikke i konteksttilstanden ved starten av funksjonen (tilstanden er bare et tomt objekt på det tidspunktet)

  2. De blir slettet fra konteksttilstanden ved slutten av funksjonen — og hvis du prøver å slette en ikke-valgfri egenskap, får du denne TS-feilmeldingen: Operandet til en 'delete'-operator må være valgfritt. deno-ts(2790)

Den andre funksjonen er i den samme mellomvarekjeden etter den første funksjonen, men matcher bare GET-forespørsler og på ruter som matcher /(.*).

Bortsett fra: Jeg er ikke sikker på hva din hensikt er med å definere ruten på den måten (hvis du bare vil at ruteren skal matche på GET-forespørsler, kan du konfigurere det ved å bruke ruteren sin instansieringsalternativ RouterOptions#methods). Uansett, du kan finne det nyttig å vite at Oaks dokumentasjon sier at den bruker path-to-regexp-biblioteket for å analysere disse rutestrengene.

Den kan se slik ut:

function setEndpointNotFound(
  context: RouterContextStrongerState<"/(.*)", AppState, Partial<ContextState>>,
): void {
  context.response.status = 404;
  context.response.body = "Endpoint not available!";
}

Hvis du vil lage en mellomvarefunksjon der du forventer at den forrige har satt kontekststatusegenskapene, kan du bruke en typevakt-predikatfunksjon:

function idsAreSet<T extends { state: Partial<ContextState> }>(
  contextWithPartialState: T,
): contextWithPartialState is T & {
  state: T["state"] & Required<Pick<T["state"], "sessionID" | "userID">>;
} {
  return (
    typeof contextWithPartialState.state.sessionID === "number" &&
    typeof contextWithPartialState.state.userID === "number"
  );
}

async function someOtherMiddleware(
  context: Context<Partial<ContextState>, AppState>,
  next: NextFn,
): Promise<void> {
  // In the main scope of the function:
  context.state.userID;
  //            ^? number | undefined
  context.state.sessionID;
  //            ^? number | undefined

  if (idsAreSet(context)) {
    // After the type guard is used, in the `true` path scope:
    context.state.userID;
    //            ^? number
    context.state.sessionID;
    //            ^? number
  } else {
    // After the type guard is used, in the `false` path scope:
    context.state.userID;
    //            ^? number | undefined
    context.state.sessionID;
    //            ^? number | undefined
  }

  await next();
}

Det er på tide å lage ruteren og bruke vår mellomvare; det er veldig enkelt:

const router = new Router<Partial<ContextState>>();
router.use(assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd);
router.get("/(.*)", setEndpointNotFound);

Og å bruke ruteren i appen er like enkelt (direkte fra koden i spørsmålet ditt):

app.use(router.routes(), router.allowedMethods());

Til slutt, la oss avslutte ved å starte serveren.

Koden i spørsmålet ditt inkluderer en applikasjons-lyttehendelses-callback-funksjon som logger serveradressen til konsollen. Mitt gjetning er at du kopierte den fra eksemplet i dokumentasjonen. Det er en fin funksjon å ha hvis du vil se en melding i konsollens stdout når serveren din starter. Her er en liten variasjon som bruker URL-konstruktøren, slik at hvis du tilfeldigvis lytter på standardporten for protokollen du bruker (f.eks. 80 på http, 443 på https), vil det biten bli utelatt fra adressen i meldingen (akkurat som i adressen som nettleseren din viser deg). Den er også kompatibel med onListen-callbacken som brukes av serve-funksjonen fra Denos std-bibliotek:

function printStartupMessage({ hostname, port, secure }: {
  hostname: string;
  port: number;
  secure?: boolean;
}): void {
  if (!hostname || hostname === "0.0.0.0") hostname = "localhost";
  const address =
    new URL(`http${secure ? "s" : ""}://${hostname}:${port}/`).href;
  console.log(`Listening at ${address}`);
  console.log("Use ctrl+c to stop");
}

app.addEventListener("listen", printStartupMessage);

Og... start serveren:

await app.listen({ port: 8080 });

Her er det fullstendige modulresultatet fra det som ble diskutert ovenfor:

import {
  Application,
  type Context,
  type RouteParams,
  Router,
  type RouterContext,
  type State as AnyOakState,
} from "https://deno.land/x/[email /mod.ts";

type EmptyObject = Record<never, never>;
type AppState = EmptyObject;

const app = new Application<AppState>({ state: {} });

type ContextState = {
  userID: number;
  sessionID: number;
};

interface RouterContextStrongerState<
  R extends string,
  AS extends AnyOakState = EmptyObject,
  S extends AS = AS,
  P extends RouteParams<R> = RouteParams<R>,
> extends Context<S, AS> {
  captures: string[];
  matched?: Exclude<RouterContext<R, P, S>["matched"], undefined>;
  params: P;
  routeName?: string;
  router: Router;
  routerPath?: string;
}

type NextFn = () => Promise<unknown>;

async function assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd(
  context: Context<Partial<ContextState>, AppState>,
  next: NextFn,
): Promise<void> {
  context.state.userID = 123;
  context.state.sessionID = 456;
  await next();
  delete context.state.userID;
  delete context.state.sessionID;
}

function setEndpointNotFound(
  context: RouterContextStrongerState<"/(.*)", AppState, Partial<ContextState>>,
): void {
  context.response.status = 404;
  context.response.body = "Endpoint not available!";
}

const router = new Router<Partial<ContextState>>();
router.use(assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd);
router.get("/(.*)", setEndpointNotFound);

app.use(router.routes(), router.allowedMethods());

// This is not necessary, but is potentially helpful to see in the console
function printStartupMessage({ hostname, port, secure }: {
  hostname: string;
  port: number;
  secure?: boolean;
}): void {
  if (!hostname || hostname === "0.0.0.0") hostname = "localhost";
  const address =
    new URL(`http${secure ? "s" : ""}://${hostname}:${port}/`).href;
  console.log(`Listening at ${address}`);
  console.log("Use ctrl+c to stop");
}

app.addEventListener("listen", printStartupMessage);

await app.listen({ port: 8080 });

Det er ganske mye informasjon, men det skjer mye inni et webserver-rammeverk, og å gjøre det på en typesikker måte er enda mer komplisert!

Relaterte artikler