Greetings for the extensive explanation provided, as I am still in the learning process of typescript, I have tried my best to articulate my question effectively. Let me begin by explaining:
// introducing the base "Failure" type which serves as the foundation for all other "Failures":
class Failure { constructor (public message: string) {} }
type FailureResult<R> = R extends Failure ? R : never;
// anything that is not a Failure is considered a Success.
type SuccessResult<R> = R extends Failure ? never : R;
// the main goal here is to have a collection of functions within an object...
type MultipleChecks<C, R> = {
[K in keyof R]: (context: C) => R[K]
}
// after executing all functions, the results are categorized into
// a type where all elements are some type of Failure (or null)...
type MappedFailure<R> = {
[K in keyof R]: FailureResult<R[K]>|null
};
// ... or a type where all elements are successes:
type MappedSuccess<R> = {
[K in keyof R]: SuccessResult<R[K]>
};
// though not absolutely necessary for the minimal reproduction, I am also
// consolidating those combined failures into a single failure:
class MultipleFailures<R> extends Failure {
readonly failures: MappedFailure<R>;
constructor (failures: MappedFailure<R>) {
super('multiple failures');
this.failures = failures;
}
}
type MappedResult<R> = MappedSuccess<R>|MultipleFailures<R>;
// Everything up until this point behaves as expected. Now, here comes
// the part that is challenging to implement correctly:
function validateMultiple<C, R> (context: C, checks: MultipleChecks<C, R>) : MappedResult<R> {
const results : MappedSuccess<R> = {} as MappedSuccess<R>
const failures : MappedFailure<R> = {} as MappedFailure<R>
let checkFailed = false;
for (const key of Object.keys(checks) as [keyof MultipleChecks<C, R>]) {
const fn = checks[key];
const result = fn(context);
if (result instanceof Failure) {
checkFailed = true;
// @ts-expect-error Type 'R[keyof R] & Failure' is not assignable
// to type 'FailureResult<R[keyof R]>'.
failures[key] = result;
} else {
// @ts-expect-error Type 'R[string]' is not assignable to type
//'SuccessResult<R[keyof R]>'.
results[key] = result;
failures[key] = null;
}
}
if (checkFailed) return new MultipleFailures(failures);
else return results;
}
For better clarity, I have included an example implementation:
class NumberTooSmall extends Failure {
constructor () { super('number too small.'); }
}
class NumberTooBig extends Failure {
constructor () { super('number too big.'); }
}
type NumberResult = { a: number, b: number } | NumberTooSmall | NumberTooBig;
function validateNumbers ({a, b}: { a: number , b: number }) : NumberResult {
if (a < 1 || b < 1) return new NumberTooSmall();
if (a > 100 || b > 100) return new NumberTooBig();
return { a, b };
};
class NameTooShort extends Failure {
constructor () { super('name too short.'); }
}
class NameTooLong extends Failure {
constructor () { super('name too long.'); }
}
type NameResult = string | NameTooShort | NameTooLong;
function validateName ({ name }: { name: string }) : NameResult {
if (name.length < 2) return new NameTooShort();
if (name.length > 5) return new NameTooLong();
return name;
};
function addByName (context: { a: number, b: number, name: string }) : string {
const validation = validateMultiple(context, {
numbers: validateNumbers,
name: validateName
});
if (validation instanceof MultipleFailures) {
let message = 'Failed to add:'
for (const f of Object.values(validation.failures)) {
if (f !== null) message += `\n${f.message}`
}
return message;
} else {
const { name, numbers } = validation;
const { a, b } = numbers;
return `${name} added ${a} + ${b} and got ${a + b}.`;
}
}
Explaining verbally, with the help of the top types FailureResult
and SuccessResult
, the function's return type is divided into failure and success, like so:
type FooContext = { fooInput: string }
type FooResult = Foo|FooErrorA|FooErrorB
function getFoo (context: FooContext) : FooResult;
type FooSuccess = SuccessResult<FooResult>; // i.e. Foo
type FooFailure = FailureResult<FooResult>; // i.e. FooErrorA|FooErrorB
Though the above segment works smoothly. The objective is to gather similar functions into an object, execute them sequentially, and then map the results into an object comprising of either all successes or all Failure|null
. From a type perspective, this concept also seems feasible using the MappedSuccess<R>
and MappedFailure<R>
types:
type MultiContext = FooContext & BarContext;
type MultiChecks = { foo: FooResult, bar: BarResult };
// either all fields have no error value, or the outcomes are condensed into
// a failure object where each field represents the failure or null.
type AllSuccess = { foo: FooSuccess, bar: BarSuccess };
type SomeFailures = { foo: FooFailure|null, bar: BarFailure|null }
type MultiResult = AllSuccess|{ failures: SomeFailures }
function validateMultiple(context: MultiContext, MultiChecks) : MultiResult;
The actual problem (as depicted in the sample implementation of validateMultiple
) arises when TypeScript does not recognize the safety of the intermediate values generated while iterating over the objects. Although I am confident about their correctness informally, it seems like I may need to use some type assertions, yet I am struggling to determine what those would entail.