gbromios
gbromios

Reputation: 462

"Mapping types" at runtime?

Apologies for the extremely verbose "minimal reproduction" here, but I'm fairly new to typescript not sure how else to actually ask my question, so I suppose I'll just start there:

// just a generic "Failure" type from which all other "Failures" are derived:
class Failure { constructor (public message: string) {} }
type FailureResult<R> = R extends Failure ? R : never;

// anything which is not a Failure is a Success.
type SuccessResult<R> = R extends Failure ? never : R;

// the purpose of the above is to have a bunch of functions living in an object...
type MultipleChecks<C, R> = {
  [K in keyof R]: (context: C) => R[K]
}

// and once all the functions have been called, to divide the result(s) into
// a type whose members are all some type of Failure (or null)...
type MappedFailure<R> = {
  [K in keyof R]: FailureResult<R[K]>|null
};
// ... or a type whose members are all successes:
type MappedSuccess<R> = {
  [K in keyof R]: SuccessResult<R[K]>
};

// probably wasn't strictly necessary for the minimal repro, but for the record I'm also
// taking those combined failures and turning them 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 part works as I expect. this is the part
// that I can't figure out how 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;
}

Including some example implementation in case the usage makes it more clear what I'm asking:

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}.`;
  }
}

With that out of the way, I'll try to explain verbally what I'm thinking. using the two types at the top FailureResult and SuccessResult, I divide divide a function's return type into failure and success, 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

that part works fine. the point is that I'd like to be able to gather functions like this into an object, call each of them in turn, then have the results be "mapped" into an object which is all successes or all Failure|null. type-wise, this also seems to work fine, using the MappedSuccess<R> and MappedFailure<R> types:

type MultiContext = FooContext & BarContext;
type MultiChecks = { foo: FooResult, bar: BarResult };

// either every field has a non-error value, or the results are condensed into
// a failure object where each field is 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 problem (as illustrated in the example implementation of validateMultiple) is that I can't seem to convince typescript that the intermediate values generated as I iterate over the objects are safe. I'm quite sure they are, at least informally. I certainly won't be surprised if I need to do some type assertions, but I can't even figure out what those would be.

Upvotes: 2

Views: 284

Answers (1)

jcalz
jcalz

Reputation: 330411

Inside the implementation of ValidateMultiple, the type of result is R[keyof R] and may or may not be narrowed to R[keyof R] & Failure. But neither of these types is seen by the compiler to be assingable to the type of failures[key] or results[k]. The relevant FailureResult<XXX> and SuccessResult<XXX> types are conditional types and when the depend on as-yet unspecified type parameters like R, they are treated as almost completely opaque. The compiler has no idea what specific value might actually be assignable to results[key], so it complains:

if (result instanceof Failure) {
  checkFailed = true;
  failures[key] = result; // error!
  // not assignable to type 'FailureResult<R[keyof R]> | null'.
} else {
  results[key] = result; // error!
  // not assignable to type 'SuccessResult<R[keyof R]>'.
  failures[key] = null;
}

If all you care about is suppressing the errors (and not significantly refactoring to try to convince the compiler that you're doing something safe), then a type assertion for each error is warranted.

You can use the error messages to give you an idea what assertions to use. In the top case, it's saying that result can't be assigned to FailureResult<R[keyof R]> | null, so presumably that's the type we want result to be assigned to in this case. So we can assert that it's FailureResult<R[keyof R]> (since we know it's not null). In the bottom case we have that result is not assingbale to SuccessResult<R[keyof T]>, so again, that seems like a good type to assert in that situation:

if (result instanceof Failure) {
  checkFailed = true;
  failures[key] = result as FailureResult<R[keyof R]>; // okay
} else {
  results[key] = result as SuccessResult<R[keyof R]> ; // okay
  failures[key] = null;
}

Now there are no errors, as desired.

Playground link to code

Upvotes: 3

Related Questions