2
votes

I have a abstract base class with a generic type parameter like this:

abstract class Base<T> {
  constructor(protected value: T) {}
}

I inherit multiple classes from the base. For example:

class TBase extends Base<number> {
  constructor(value: number) { 
    super(value); 
  }
}

class XBase extends Base<string> {
  constructor(value: string) { 
    super(value); 
  }
}

Now I want to write a factory function that returns a new Base based on my input properties:

type ValidTypes = 'string' | 'integer' | 'decimal' | 'boolean' | 'datetime';

type validFor<T> = T extends 'string' | 'datetime'
  ? string
  : T extends 'integer' | 'decimal'
  ? number
  : T extends 'boolean'
  ? boolean
  : never;

function getBase<T extends ValidTypes, P extends validFor<T>>(type: T, value: P): Base<P> {
 switch(type) {
   case 'number': new TBase(coerceNumber(value)); break;
   ...
 } 
}

When passing 'string' as first parameter, the second can only be of type string. For 'decimal', type P can only be a number.

But I have two problems. When calling the function like this:

getBase('string', '5');

It works, but it says that the signature is

function getBase<'string', '5'>(type: 'string', value: '5'): Base<'5'>

It don't understand why it's not resolving to string but instead to the value of value?

The other problem is, that when I return a new TBase() it states that it could also be a string or boolean:

"'number' is assignable to the constraint of type 'P', but 'P' could be instantiated with a different subtype of constraint 'string | number | boolean'."

I searched a lot about this, but couldn't get around it. Could someone explain to me why excactly this happens? Even when i explicit cast it to a Base it throws the error (return new TBase() as Base)

Another approach I tried was with using function overloads, but this looks kinda weird and not right:

getBase(type: 'decimal' | 'integer', value: number): Base<number>
getBase(type: 'string' | 'datetime', value: string): Base<string>
getBase(type: 'boolean', value: boolean): Base<boolean>
getBase(type: ValidTypes, value: number | boolean | string): Base<number | boolean | string> {
  ...
}

I want to something like this:


getBase('string', 'thisMustBeAString'); // => Base<string>;
getBase('decimal', 54 /* Must be a number */) // => Base<number>;

What am I missing? I'm quiet struggling with this for a long time now.. Thanks in advance

Edit:

Playground Link

1
Please share coerceNumber Unable to reproduce your code. Could you please share it in typescript playground? - captain-yossarian
@captain-yossarian coerceNumber is actually coerceNumberProperty from @angular/cdk/coercion and coerces a input value to be a number. I added a similar implemention to the playground (link in the post) - Urastor
try to remove return type from getBase function. Please let me know if it is ok for you - captain-yossarian
@captain-yossarian Just removing the type would just resolve to TBase | XBase, which isn't really what I want - Urastor

1 Answers

0
votes

I tried very hard to get your first approach to work in a fully type-safe manner without casts, but failed.

This question also deals with return types whose types depend on argument types, and several answers suggest to use overloading. As you've already found, that's one way to solve it – although the return type of your implementation should not be Base<number | string | boolean> but rather Base<number> | Base<string> | Base<boolean>.

If you're okay with changing the call syntax a bit, you can do this and still remain fully type safe:

// Deliberately leaving this untyped, so we get the most specific inferred type possible.
const factories = {
  'integer': (value: number) => new TBase(value) as Base<number>,
  'string': (value: string) => new XBase(value) as Base<string>,
}

const a: Base<string> = factories['string']('thisMustBeAString');
const b: Base<number> = factories['integer'](54);
const c: Base<number> = factories['integer']('thisWillNotCompile');
const d: Base<number> = factories['string']('neitherWillThis');
const e: Base<string> = factories[a.value]('norWillThis');

Some ways to extract types from this map:

// Extract the names of the types: 'integer' | 'string'.
type TypeName = keyof typeof factories;

// Extract the type of the factory function, for example (value: number) => Base<number>.
type FactoryType<T extends TypeName> = typeof factories[T];

// Extract the type of argument that goes into the factory.
type FactoryArgumentType<T extends TypeName> = Parameters<FactoryType<T>>[0];

// Extract the factory's return type.
// Currently always equal to its argument type, but it doesn't need to be.
type FactoryReturnType<T extends TypeName> = ReturnType<FactoryType<T>>;

Using these, you can in fact implement getBase in a rather ugly way:

function getBase<T extends TypeName>(type: T, value: FactoryArgumentType<T>): FactoryReturnType<T> {
  return factories[type](value as never) as FactoryReturnType<T>;
}

I don't quite understand why the strange value as never is needed to make the compiler accept this, but it emits the right code in the end.