Can You Strict Generic Types or Give One Parameter More Than One Type

Can you Strict Generic types or give one parameter more than one type ?

Let's suppose that you could use a OR operator to combine protocols, what would you be able to do with something of type (Int | String)?

Not everything you can do to an Int can be done on (Int | String), because it might be a string underlyingly. Similarly, not everything you can do to an String can be done on (Int | String), because it might be a Int underlyingly.

Now you might say "Ah. I know that Int and String both have a description property. I should be able to access description on a variable of type (Int | String)."

Well, in that case, you can just create such a protocol yourself and only have Int and String conform to it:

protocol IntOrString {
var description: String { get }
}

extension Int : IntOrString {}
extension String : IntOrString {}

(Note that description is already defined in CustomStringConvertible. For the sake of argument, imagine that does not exist.)

Inferred type in generic with multiple type parameters

You are close with foo2. Here is what it's supposed to be to make it work.

declare function foo2<T, K = keyof T>(x: K)
let t2 = foo2<Bar>('t');

Instead of expecting a new generic type parameter, you can assign one to a type (in this case the type of T).

It's also possible to directly assign the paramaters type to the keyof the given generic type T.

declare function foo4<T>(x: keyof T);
let t4 = foo4<Bar>('t');

Can we use generic to allow only specific types instead of any type T?

Make the constructor private:

private C(T param){

And then provide static factory methods to create instances of particular types:

public static <T extends A> C<T> create(T param) {
return new C<>(param);
}

public static <T extends B> C<T> create(T param) {
return new C<>(param);
}

This doesn't prevent you from using the type C<SomeOtherType>; you just can't create an instance of it.

restrict generic type T to specific types

Short answer: Nope.


Typescript is structural, not nominal. That makes that if two types has the same interface, then they are the same type. That even includes class instances versus object literals.

We can test this with something like:

type Exact<A, B> = A extends B ? B extends A ? true : false : false

This type alias takes two parameters. If A extends B, and vice versa, then we know typescript considers them to be identical.

type IBase_Base = Exact<IBase, Base> // true
type Base_Derived = Exact<Base, Derived> // true
type Derived_ObjLiertal = Exact<Base, { prop1: string }> // true

So class Derived extends Base { } is identical to Base as far as typescript is concerned. in fact a class instance and an object literal are considered identical as long as the instance has the shape as the literal.


That said, if the derived class was different in anyway, then you could notice that with the help if this Exact type and forbid it.

First you could make a type to enforce that a type exactly match, like:

type EnforceExact<Constraint, T> = Exact<Constraint, T> extends true ? T : never

Then make your function use this helper:

function doSomething<T extends IBase>(obj: EnforceExact<IBase, T>) {
// do something
}

Now if we make the Derived class different somehow:

class DerivedWithAddition extends Base {
foo: number = 123
}

Then we get a type error when we try to pass this in:

doSomething(new DerivedWithAddition()) // not assignable to parameter of type 'never'.

Playground


You could also use the brand workaround as suggested in the answer that @kaya3 linked. However, unless there's a very good reason, you probably shouldn't. This structural equivalence is a pretty great feature of typescript. Your life will be easier if you just let someone pass in whatever they want, as long as it has the correct interface.

What is the best way to pass generic function that resolves to multiple types

You can't do this in F# with functions. Functions lose genericity when passed as values.

However, F# does have a mechanism for doing it anyway, albeit a bit awkwardly: interfaces. Interface methods can be generic, so you can use them to wrap your generic functions:

type Wrapper =
abstract member f<'a> : 'a -> 'a

let y (w: Wrapper) =
let a = w.f 1
let b = w.f 2L
(a, b)

let genericFn x = x

// Calling y:
y { new Wrapper with member __.f x = genericFn x }

The downside is, you can't go back to higher-order functions, lest you lose genericity. You have to have interfaces all the way down to the turtles. For example, you can't simplify the instance creation by abstracting it as a function:

let mkWrapper f = 
// no can do: `f` will be constrained to a non-generic type at this point
{ new Wrapper with member __.f x = f x }

But you can provide some convenience on the other side. At least get rid of type annotations:

type Wrapper = abstract member f<'a> (x: 'a): 'a

let callF (w: Wrapper) x = w.f x

let y w =
let a = callF w 1
let b = callF w 2L
(a,b)

(NOTE: there may be minor syntactic mistakes in the above code, as I'm writing on my phone)

strictFunctionTypes restricts generic type

This is at it's core an issue of variance. So first a variance primer:

About variance

Given a generic type Foo<T>, and two related types Animal and Dog extends Animal. There are four possible relationships between Foo<Animal> and Foo<Dog>:

  1. Covariance - The arrow of inheritance points in the same direction for Foo<Animal> and Foo<Dog> as it does for Animal and Dog, so Foo<Dog> is a sub type of Foo<Animal>, which also means Foo<Dog> is assignable to Foo<Animal>
type CoVariant<T> = () => T
declare var coAnimal: CoVariant<Animal>
declare var coDog: CoVariant<Dog>
coDog = coAnimal; // br>coAnimal = coDog; // ✅

  1. Contravariance - The arrow of inheritance points in the opposite direction for Foo<Animal> and Foo<Dog> as it does for Animal and Dog, so Foo<Animal> is a actually sub type of Foo<Dog>, which also means Foo<Animal> is assignable to Foo<Dog>
type ContraVariant<T> = (p: T) => void
declare var contraAnimal: ContraVariant<Animal>
declare var contraDog: ContraVariant<Dog>
contraDog = contraAnimal; // ✅
contraAnimal = contraDog; // br>

  1. Invariance - Although Dog and Animal are related Foo<Animal> and Foo<Dog> have no relationship whatsoever between them, so neither is assignable to the other.
type InVariant<T> = (p: T) => T
declare var inAnimal: InVariant<Animal>
declare var inDog: InVariant<Dog>
inDog = inAnimal; // br>inAnimal = inDog; // br>

  1. Bivariance - If Dog and Animal are related, both Foo<Animal> is a subtype of Foo<Dog> and Foo<Animal> is a subtype of Foo<Dog> meaning either type is assignable to the other. In a stricter type system, this would be a pathological case, where T might not actually be used, but in typescript, methods parameter positions are considered bi-variant.

class BiVariant<T> { m(p: T): void {} }
declare var biAnimal: BiVariant<Animal>
declare var biDog: BiVariant<Dog>
biDog = biAnimal; // ✅
biAnimal = biDog; // ✅

All Examples - Playground Link

So the question is how does the usage of T impact variance? In typescript the position of a type parameter determines variance, some examples :

  1. Co-varaint - T is used in as a field or as the return type of a function
  2. Contra-varaint - T is used as the parameter of a function signature under strictFunctionTypes
  3. Invariant - T is used in both a covariant and contravariant position
  4. Bi-variant - T is used as the parameter of a method definition under strictFunctionTypes, or as the parameter type of either method or function if strictFunctionTypes are off.

The reasoning for the different behavior of method and function parameters in strictFunctionTypes is explained here:

The stricter checking applies to all function types, except those originating in method or constructor declarations. Methods are excluded specifically to ensure generic classes and interfaces (such as Array) continue to mostly relate covariantly. The impact of strictly checking methods would be a much bigger breaking change as a large number of generic types would become invariant (even so, we may continue to explore this stricter mode).

Back to the question

So lets see how, the usages of T impact the variance of Foo.

  • barCallback!: (val: T) => void; - used as a parameter in member that is a function -> contra variant position

  • baz(callback: ((val: T) => void)): void - used as a parameter in the callback parameter of another function. This is a bit tricky, spoiler alert, this will turn out to be covariant. Lets consider this simplified example:

type FunctionWithCallback<T> = (cb: (a: T) => void) => void

// FunctionWithCallback<Dog> can be assigned to FunctionWithCallback<Animal>
let withDogCb: FunctionWithCallback<Dog> = cb=> cb(new Dog());
let aliasDogCbAsAnimalCb: FunctionWithCallback<Animal> = withDogCb; // ✅
aliasDogCbAsAnimalCb(a => a.animal) // the cb here is getting a dog at runtime, which is fine as it will only access animal members

let withAnimalCb: FunctionWithCallback<Animal> = cb => cb(new Animal());
// FunctionWithCallback<Animal> can NOT be assigned to FunctionWithCallback<Dog>
let aliasAnimalCbAsDogCb: FunctionWithCallback<Dog> = withAnimalCb; // br>aliasAnimalCbAsDogCb(d => d.dog) // the cb here is getting an animal at runtime, which is bad, since it is using `Dog` members

Playground Link

In the first example, the callback we pass to aliasDogCbAsAnimalCb expects to receive an Animal, so it only uses Animal members. The implementation withDogCb will create a Dog and pass it to the callback, but this is fine. The callback will work as expected using just the base class properties it expects are there.

In the second example, the callback we pass to aliasAnimalCbAsDogCb expects to receive a Dog, so it uses Dog members. But the implementation withAnimalCb will pass into the callback an instance of an animal. This can leas to runtime errors as the callback ends up using members that are not there.

So given it is only safe to assign FunctionWithCallback<Dog> to FunctionWithCallback<Animal>, we arrive at the conclusion that such a usage of T determines covariance.

Conclusion

So we have T used in both a covariant and a contravariant position in Foo, this means that Foo is invariant in T. This means that Foo<any[] | { [s: string]: any }> and Foo<any[]> are actually unrelated types as far as the type system is concerned. And while overloads are looser in their checks, they do expect the return type of the overload and the implementation to be related (Either the implementation return or the overloads return must be a subtype of the other, ex)

Why some changes make it work:

  • Turning off strictFunctionTypes will make the barCallback site for T bivariant, so Foo will be covariant
  • Converting barCallback to a method, makes the site for T bivariant so Foo will be covariant
  • Removing barCallback will remove the contravariant usage and so Foo will be covariant
  • Removing baz will remove the covariant usage of T making Foo contravariant in T.

Workarounds

You can keep strictFunctionTypes on and carve out an exception just for this one callback to keep it bivariant, by using a bivariant hack (explained here for a more narrow use case, but the same principle applies):


type BivariantCallback<C extends (... a: any[]) => any> = { bivarianceHack(...val: Parameters<C>): ReturnType<C> }["bivarianceHack"];

class Foo<T> {
static manyFoo(): Foo<any[] | { [s: string]: any }>;
static manyFoo(): Foo<any[]> {
return ['stub'] as any;
}

barCallback!: BivariantCallback<(val: T) => void>;

constructor() {
// get synchronously from elsewhere
(callback => {
this.barCallback = callback;
})((v: any) => {});
}

baz(callback: ((val: T) => void)): void {}
}

Playground Link

Typescript generic type parameters: T vs T extends {}

Note: I'll assume you're using a version of TypeScript 3.5 or later; in TypeScript 3.5 a change was made so that generic type parameters are implicitly constrained by unknown instead of the empty object type {}, and some minor details about the difference between funcA() and funcB() changed. I don't want to make a long post even longer by talking about how things used to be in TS3.4 and below.


If you don't explicitly constrain a generic type parameter via extends XXX, then it will implicitly be constrained by unknown, the "top type" to which all types are assignable. So in practice that means the T in funcA<T>() could be absolutely any type you want.

On the other hand, the empty object type {}, is a type to which nearly all types are assignable, except for null and undefined, when you have enabled the --strictNullChecks compiler option (which you should). Even primitive types like string and number are assignable to {}.

So compare:

function funcA<T>() { }
funcA<undefined>(); // okay
funcA<null>(); // okay
funcA<string>(); // okay
funcA<{ a: string }>(); // okay

with

function funcB<T extends {}>() { }
funcB<undefined>(); // error
funcB<null>(); // error
funcB<string>(); // okay
funcB<{ a: string }>(); // okay

The only difference is that T extends {} forbids null and undefined.


It might be a little confusing that {}, a so-called "object" type, would accept primitives like string and number. It helps to think of such curly-brace-surrounded types like {} and {a: string} as well as all interface types not necessarily as "true" object types, but as types of values where you can index into them as if they were objects without getting runtime errors. Primitives except for null and undefined are "object-like" in that you can treat them as if they were wrapped with their object equivalents:

const s: string = "";
s.toUpperCase(); // okay

And therefore even primitives like string are assignable to curly-brace-surrounded types as long as the members of those types match:

const x: { length: number } = s; // okay

If you really need to express a type that only accepts "true", i.e., non-primitive objects, you can use the object:

const y: object & { length: number } = s; // error
const z: object & { length: number } = { length: 10 }; // okay

But I (seriously) digress.


Okay, hope that helps; good luck!

Playground link to code



Related Topics



Leave a reply



Submit