redux - Hur skriver jag en funktionstyp som strikt kräver sina parametrar i Typescript
Jag har stött på ett problem med combineReducers som inte är tillräckligt strikt och jag är inte säker på hur jag ska lösa det:
interface Action {
type: any;
}
type Reducer<S> = (state: S, action: Action) => S;
const reducer: Reducer<string> = (state: string, action: Action) => state;
const reducerCreator = (n: number): Reducer<string> => reducer;
interface ReducersMapObject {
[key: string]: Reducer<any>;
}
const reducerMap: ReducersMapObject = {
test: reducer,
test2: reducerCreator
}
Jag skulle förvänta mig att reducerMap kastar ett fel eftersom reducerCreator inte är en reducer (det är en funktion som tar en sträng och returnerar en reducer), men TypeScript har inga problem med detta.
Det verkar som att källan till problemet är att Reducer i princip kokar ner till any => any eftersom funktioner med färre parametrar kan tilldelas till funktioner som tar fler parametrar.
Detta betyder att typen ReducersMapObject i princip bara är {[key: string]: function}
Finns det ett sätt att göra Reducer-typen striktare när det gäller att kräva båda parametrarna eller ett annat sätt att få större säkerhet på att ReducersMapObject faktiskt innehåller reducer-funktioner?
Denna kod kompileras alla i TypeScript-lekplatsen om du försöker replikera
Svar #1
Bra fråga... det finns två gångbara alternativ mot slutet av detta ganska långa svar. Du ställde två frågor, jag svarade på varje separat.
Fråga 1:
Finns det ett sätt att göra Reducer-typen striktare när det gäller att kräva båda parametrarna...
Det kommer att vara svårt att uppnå det på grund av två hinder i TypeScript-funktioner.
Hinder 1: Att kassera funktionsparametrar
Ett hinder, som du redan har noterat, dokumenteras här under rubriken "Jämföra två funktioner." Det står att "vi tillåter 'kastande' av parametrar." Det vill säga, funktioner med färre parametrar kan tilldelas funktioner med fler parametrar. Motiveringen finns i FAQ. Kort sagt är följande tilldelning säker eftersom funktionen med färre parametrar "säkert kan ignorera extra parametrar."
const add: (x: number, y: number) =
(x: number) => { return x; }; // works and is safe
Hinder 2: Funktionsparameterns bivarians
Ett andra hinder är att funktionsparametrar är bivarians. Det betyder att vi inte kan kringgå detta problem med en användardefinierad typparameter. I vissa språk skulle vi kunna definiera Pair tillsammans med en funktion som accepterar en Pair.
class Pair {
x: number;
y: number;
}
let addPair: (p: Pair) => number;
I språk med kovarianta funktioner skulle ovanstående begränsa argument till subtyper av Pair.
I TypeScript följer enkel typ-tilldelning de förväntade substituerbarhetsreglerna, men funktioner följer bivarianta regler. I sin enkla typ-tilldelning tillåter TypeScript oss att tilldela typen Pair till typen Single men inte att tilldela typen Single till typen Pair. Detta är en förväntad substitution.
class Single {
x: number;
}
let s: Single = new Pair(); // works
let p: Pair = new Single(); // fails because of a missing property.
TypeScripts funktioner är dock bivarianta och följer inte samma begränsningar.
let addSingle: (s: Single) => number;
addSingle = (p: Pair) => p.x + p.y; // as expected, Pair is assignable to Single.
let addPair: (p: Pair) => number;
addPair = (s: Single) => s.x; // surprise, Single is assignable to Pair!
Resultatet är att en funktion som förväntar sig en Pair kommer att acceptera en Single.
Konsekvenser för Reducers
Ingen av de följande två teknikerna kommer att upprätthålla antalet parametrar (eller klassens egenskaper) som Reducer-implementationer måste acceptera.
class Action { }
// no restriction - TypeScript allows discarding function parameters
type Reducer01<S> = (state: S, action: Action) => S;
const reducer01: Reducer01<number> = (state: number) => 0; // works
// no restriction - TypeScript functions have parameter bivariance
class ReducerArgs<S> {
state: S;
action: Action;
}
type Reducer02<S> = (args: ReducerArgs<S>) => S;
const reducer02 = (args: { state: number }) => 0; // works
Det kommer förmodligen inte att vara ett problem i praktiken, eftersom det är säkert att låta ReducersMapObject acceptera en Reducer med färre parametrar. Kompilatorn kommer fortfarande att säkerställa att:
- varje anrop till en
Reducerinkluderar allaReducer-argument, och - varje implementering av en
Reducerarbetar endast med sin (möjligen korta) parameterlista.
Fråga 2:
...eller ett annat sätt att få mer förtroende för att ReducersMapObject faktiskt innehåller reducer-funktioner?
En sak som vi försöker göra är att göra reducerCreator-funktionen (och andra funktioner med ovanlig form) inkompatibla med Reducer<S>-funktionstypen. Här är två möjliga alternativ.
Hållbart alternativ 1: Användardefinierad parametertyp
Den andra tekniken från ovan, som använder en användardefinierad typ kallad ReducerArgs<S>, kommer att ge oss mer självförtroende. Den kommer inte att ge fullständigt självförtroende, eftersom vi fortfarande kommer att ha bivarians, men den kommer att säkerställa att kompilatorn avvisar reducerCreator. Så här kan det se ut:
interface Action {
type: any;
}
// define an interface as the parameter for a Reducer<S> function
interface ReducerArgs<S> {
state: S;
action: Action
}
type Reducer<S> = (args: ReducerArgs<S>) => S;
const reducer: Reducer<string> = (args: ReducerArgs<string>) => args.state;
const reducerCreator = (n: number): Reducer<string> => reducer;
interface ReducersMapObject {
[key: string]: Reducer<any>;
}
const reducerMap: ReducersMapObject = {
test: reducer,
test2: reducerCreator // error!
}
Hållbar alternativ 2: Generiska typer och unionstyper
Ett annat alternativ är att använda ett generiskt ReducerMapObject<T> som detta:
interface ReducersMapObject<T> {
[key: string]: Reducer<T>;
}
Och sedan parameterisera det med en unionstyp som listar typerna för alla reducerare.
const reducer: Reducer<string> = (state: string, action: Action) => state;
const reducer1: Reducer<number> = (state: number, action: Action) => state;
const reducerMap: ReducersMapObject<string | number> = {
test: reducer,
test1: reducer1,
test2: reducerCreator // error!
}
Resultatet blir att any => any blir T => T, där T är en av typerna som listas i unionen. (För övrigt vore det bra att ha en typ som säger, "x kan vara vilken typ som helst, så länge det är samma typ som y.")
Även om båda ovan involverar mer kod och är lite klumpiga, fyller de ditt syfte. Detta var ett intressant forskningsprojekt. Tack för frågan!
