Reputation: 3341
I have been trying to type a 'configuration object' used to construct an indexed store. I have simplified the case down to its minimum, but hopefully the motivation still makes some sense.
I'm concerned the model is too self-referential to be able to be expressed in typescript, as I keep hitting dead ends trying to define the types. However, I don't know what the next best approach would be that DOES align well with typescript's expressivity.
In particular I can't find a good pattern for defining the type constraints to ensure that functions that consume from some index are typed correctly for the rows emitted by that index.
A valid index-configuration looks like chainedMap
below. If I can solve my typing problem, then compiler errors should be generated when one of the function arguments doesn't match the return value of the function(s) it is 'chained' from.
const chainedMap = { //configures a store for strings
length: (value: string) => value.length, // defines a 'length' index populated by numbers
threshold: { // defines a 'threshold' index, derived from length, populated by booleans
length: (value: number) => value >= 10,
},
serialise: { // defines a serialise index, derived from both length and threshold, populated by strings
length: (value: number) => value.toString(),
threshold: (value: boolean) => value.toString(),
},
} as const;
Because of the intent to chain indexes together, the argument types of some functions are coupled to the output types of other functions in the same object.
A valid derived index like 'threshold' or 'serialise' must reference only named indexes which actually exist, such as 'length', 'threshold' or 'serialise', and must define a mapping function that consumes the type of data contained in that index e.g. if you consume from 'length' your function should accept numbers, and to consume from 'threshold', your function should accept booleans.
ATTEMPTED TYPINGS
Top-level named FUNCTIONS in the chainedMap
create the primary indexes. They consume rows as they are added to the store, and they emit rows into their correspondingly-named index. For example, isolating the top level 'length' index it could be typed like this, for a store that takes string rows...
const lengthIndex: PrimaryMapping<string, number> = {
length: (value: string) => value.length,
} as const;
Top level OBJECTS in the chainedMap
configuration are indexes derived from indexes. The objects contain named functions which consume rows from their correspondingly named index to generate rows in the derived index. For example, isolating the top level 'threshold' property on its own (which transforms rows from the length index into an index of booleans) it could be typed like this to consume the rows coming from the length index...
const lengthThresholdIndex: SecondaryMapping<typeof lengthIndex, boolean> = {
threshold: {
length: (value: number) => value >= 10,
},
} as const;
Finally it should be possible to derive an index from a derived index, making it possible to construct arbitrary chains. Isolating a mapping from the 'serialise' index, it might be typed like this...
const thresholdSerialisedIndex: SecondaryMapping<
typeof lengthThresholdIndex,
string
> = {
serialise: {
threshold: (value: boolean) => value.toString(),
},
} as const;
I arrived at these definitions of Primary and Secondary index to be able to construct the config object in a more-or-less type-safe way but with a huge increase in complexity compared to the original simple config object. The type definitions and composition needed to recreate the simple config is shown below...
interface PrimaryMapping<In, Out> {
[indexCreated: string]: (value: In) => Out;
}
interface SecondaryMapping<
Index extends PrimaryMapping<any, any> | SecondaryMapping<any, any>,
Out
> {
[indexCreated: string]: {
[fromIndex: string]: (
value: Index extends PrimaryMapping<any, infer In>
? In
: Index extends SecondaryMapping<any, infer In>
? In
: never
) => Out;
};
}
const lengthIndex: PrimaryMapping<string, number> = {
length: (value: string) => value.length,
} as const;
const lengthThresholdIndex: SecondaryMapping<typeof lengthIndex, boolean> = {
threshold: {
length: (value: number) => value >= 10,
},
} as const;
const lengthSerialisedIndex: SecondaryMapping<typeof lengthIndex, string> = {
serialise: {
length: (value: number) => value.toString(),
},
} as const;
const thresholdSerialisedIndex: SecondaryMapping<
typeof lengthThresholdIndex,
string
> = {
serialise: {
threshold: (value: boolean) => value.toString(),
},
} as const;
const index = {
...lengthIndex,
...lengthThresholdIndex,
serialise: {
...lengthSerialisedIndex.serialise,
...thresholdSerialisedIndex.serialise,
},
} as const;
However, I am struggling to find a good way to combine these to benefit from the simplicity of the original, terse configuration object, but with type-checking. To get any typings to work I seem to have to isolate these chains in both typing and declaration which ends up a horrible mess.
Ideally I would have e.g. an Index type which would raise two compiler errors in the broken example below
threshold: (value: number) => value.toString()
has a number
argument, but the threshold index returns boolean
rows.foo: (value: boolean) => !value
references an index 'foo' which doesn't exist as a top-level property of chainedMap.const chainedMap: Index<string> = {
length: (value: string) => value.length,
threshold: {
length: (value: number) => value >= 10,
foo: (value: boolean) => !value,
},
serialise: {
length: (value: number) => value.toString(),
threshold: (value: number) => value.toString(),
},
} as const;
I feel I came close when I was able to define a single Recursive Mapped type that combined elements of both Primary and Secondary like...
interface Index<
In,
Out,
I extends Index<any, In, any> | never = never
> {
[indexName: string]:
| ((value: In) => Out)
| {
[deriveFromName: string]: (
value: I[typeof deriveFromName] extends (...args: any[]) => infer In
? In
: I[typeof deriveFromName][keyof I[typeof deriveFromName]] extends (
...args: any[]
) => infer In
? In
: never
) => Out;
};
}
...but it would have to be used with a reference to its own type typeof chainedMap
which is illegal...
const chainedMap : Index <string, any, typeof chainedMap> = {
length: (value: string) => value.length,
threshold: {
length: (value: number) => value >= 10,
},
serialise: {
length: (value: number) => value.toString(),
threshold: (value: boolean) => value.toString(),
},
} as const;
Is it possible to have a self-referential type like this?
Is there an alternative pattern that would enforce the logical integrity of functions in my simply-declared configuration object?
Upvotes: 2
Views: 970
Reputation: 3341
I now have an adequate approach that allows me to progress. It can't infer everything from a simple config object. The approach requires minor 'duplication' of types. However, being explicit in this way could be seen as a virtue.
Valid declarations now look like...
const validConfig: Config<
string,
{ length: number; threshold: boolean; serialise: string }
> = {
length: (value: string) => value.length,
threshold: {
length: (value: number) => value >= 10,
},
serialise: {
length: (value: number) => value.toString(),
threshold: (value: boolean) => value.toString(),
},
} as const
Config
explicitly declares generic parameters, one for the type of Stored
item accepted in the store, and another as an ephemeral Sample
type (which is never instantiated) to define index names and the type of valid entries in that index.
Validation rules are projected from these two types to enforce constraints on functions that create direct indexes or derived indexes, correctly generating compile errors for invalid structures.
// Function that populates an index by transforming items added to the store, or to other indexes' rows
type IndexFn<RowIn, RowOut> = (value: RowIn) => RowOut;
// A map that populates an index by transforming rows from one or more named indexes
type IndexMap<Sample, RowOut> = Partial<{
[consumedIndex in keyof Sample]: IndexFn<Sample[consumedIndex], RowOut>;
}>;
// config combining direct indexes (functions that index items added to the store)
// and derived indexes (maps of functions that index other named indexes' rows)
type Config<Stored, Sample> = {
[IndexName in keyof Sample]:
| IndexFn<Stored, Sample[IndexName]> // a direct index
| IndexMap<Sample, any>; // a derived index
};
Examples of invalid configs which generate compile errors look like this...
const invalidIndexName: Config<
string,
{ length: number; threshold: boolean; serialise: string }
> = {
length: (value: string) => value.length,
threshold: {
length: (value: number) => value >= 10,
},
serialise: {
length: (value: number) => value.toString(),
threshold: (value: boolean) => value.toString(),
// there is no index foo
foo: (value: number) => value.toString(),
},
} as const;
const invalidDirectIndexArg: Config<
string,
{ length: number; negate: boolean }
> = {
length: (value: string) => value.length,
negate: {
// length is a number not a boolean
length: (value: boolean) => !value,
},
} as const;
const invalidDerivedIndexArg: Config<
string,
{ length: number; threshold: boolean; serialise: string }
> = {
length: (value: string) => value.length,
threshold: {
length: (value: number) => value >= 10,
},
serialise: {
// threshold is a boolean not a number
threshold: (value: number) => value.toString(),
},
} as const;
A slight remaining frustration is that I have functioning 'Extract' types which can correctly infer the Stored and Sample types directly from the config object, but I still can't find a way to avoid declaring the index types. Utility extraction types are demonstrated as follows...
/** Utility type that extracts the stored type accepted by direct index functions */
type ExtractStored<C extends Config<any, any>> = {
[IndexName in keyof C]: C[IndexName] extends IndexFn<infer RowIn, any>
? RowIn
: never;
}[keyof C];
/** Extracts the type emitted by a specific named index, whether direct or derived */
type ExtractRow<
C extends Config<any, any>,
IndexName extends keyof C
> = C[IndexName] extends IndexFn<any, infer DirectRowOut>
? DirectRowOut
: C[IndexName] extends IndexMap<any, infer DerivedRowOut>
? DerivedRowOut
: never;
/** Extracts an ephemeral Sample 'object' type mapping all named indexes to the index's row type. */
type ExtractSample<C extends Config<any, any>> = {
[IndexName in keyof C]: ExtractRow<C, IndexName>;
};
/** Prove extraction of key aspects */
type TryStored = ExtractStored<typeof validConfig>; //correctly extracts string as the input type
type TrySample = ExtractSample<typeof validConfig>; //correctly creates type mapping index names to their types
type LengthType = ExtractRow<typeof validConfig, "length">; //correctly evaluates to number
type ThresholdType = ExtractRow<typeof validConfig, "threshold">; //correctly evaluates to boolean
type SerialiseType = ExtractRow<typeof validConfig, "serialise">; //correctly evaluates to string
In the future if anyone can exploit these extracted types to allow e.g. a simple validate(indexConfig)
call to raise compiler errors without declaring explicit generic Store,Sample inline that would be an improvement.
All of the above examples can be experimented with in the playground at https://tsplay.dev/wXk3QW
Upvotes: 0
Reputation: 33091
Please don't treat it as a complete answer. It is a WIP. Just want to clarify. Consider this exmaple:
const chainedMap = {
length: (value: string) => value.length,
threshold: {
length: (value: number) => value >= 10,
},
serialise: {
length: (value: number) => value.toString(),
threshold: (value: boolean) => value.toString(),
},
} as const;
type Fn = (...args: any[]) => any
type TopLevel = Record<string, Fn>
const validation = <
Keys extends string,
Props extends Fn | Record<Keys, Fn>,
Config extends Record<Keys, Props>
>(config: Validate<Config>) => config
type Validate<
Original extends Record<
string,
Fn | Record<string, Fn>
>,
Nested = Original, Level = 0> =
(Level extends 0
? {
[Prop in keyof Nested]:
Nested[Prop] extends Fn
? Nested[Prop]
: Validate<Original, Nested[Prop], 1>
}
: (keyof Nested extends keyof Original
? (Nested extends Record<string, Fn>
? {
[P in keyof Nested]: P extends keyof Original
? (Original[P] extends (...args: any) => infer Return
? (Parameters<Nested[P]>[0] extends Return
? Nested[P]
: never)
: never)
: never
}
: never)
: never)
)
type Result = Validate<typeof chainedMap>
validation({
length: (value: string) => value.length,
threshold: {
length: (value: number) => value >= 10,
},
serialise: {
length: (value: number) => value.toString(), // ok
},
})
validation({
length: (value: string) => value.length,
threshold: {
length: (value: number) => value >= 10,
},
serialise: {
length: (value: string) => value.toString(), // error, because top level [length] returns number
},
})
However, I'm not sure about treshhold
. You did not provide it as a top level function but using it in nested object. Probably I did not understand something. Could you please leave a feedback?
P.S. Code is messy, I will refactor it and make it more clean
Upvotes: 1