New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mixin classes #13743

Merged
merged 4 commits into from Jan 30, 2017

Conversation

Projects
None yet
@ahejlsberg
Copy link
Member

ahejlsberg commented Jan 30, 2017

This PR expands upon #13604 to add support for mixin classes and constructors. The PR includes type system support for the ECMAScript 2015 mixin class pattern described here and here as well as rules for combining mixin construct signatures with regular construct signatures in intersection types.

In the following, the term mixin constructor type refers to a type that has a single construct signature with a single rest argument of type any[] and an object-like return type. For example, given an object-like type X, new (...args: any[]) => X is a mixin constructor type with an instance type X.

A mixin class is a class declaration or expression that extends an expression of a type parameter type. The following rules apply to mixin class declarations:

  • The type parameter type of the extends expression must be constrained to a mixin constructor type.
  • The constructor of a mixin class (if any) must have a single rest parameter of type any[] and must use the spread operator to pass those parameters as arguments in a super(...args) call.

Given an expression Base of a parametric type T with a constraint X, a mixin class class C extends Base {...} is processed as if Base had type X and the resulting type is the intersection typeof C & T. In other words, a mixin class is represented as an intersection between the mixin class constructor type and the parametric base class constructor type.

When obtaining the construct signatures of an intersection type that contains mixin constructor types, the mixin construct signatures are discarded and their instance types are mixed into the return types of the other construct signatures in the intersection type. For example, the intersection type { new(...args: any[]) => A } & { new(s: string) => B } has a single construct signature new(s: string) => A & B.

Putting all of the above rules together in an example:

class Point {
    constructor(public x: number, public y: number) {}
}

class Person {
    constructor(public name: string) {}
}

type Constructor<T> = new(...args: any[]) => T;

function Tagged<T extends Constructor<{}>>(Base: T) {
    return class extends Base {
        _tag: string;
        constructor(...args: any[]) {
            super(...args);
            this._tag = "";
        }
    }
}

const TaggedPoint = Tagged(Point);

let point = new TaggedPoint(10, 20);
point._tag = "hello";

class Customer extends Tagged(Person) {
    accountBalance: number;
}

let customer = new Customer("Joe");
customer._tag = "test";
customer.accountBalance = 0;

Effectively, a mixin class declaration is required to pass its constructor arguments through to the abstract base class constructor, and the result is an intersection of the declared class constructor and the base class constructor. For example, adding explicit type annotations to the code above:

interface Tagged {
    _tag: string;
}

function Tagged<T extends Constructor<{}>>(Base: T): Constructor<Tagged> & T {
    return class extends Base {
        _tag: string;
        constructor(...args: any[]) {
            super(...args);
            this._tag = "";
        }
    }
}

const TaggedPoint: Constructor<Tagged> & typeof Point = Tagged(Point);

let point: Tagged & Point = new TaggedPoint(10, 20);
point._tag = "hello";

The type of TaggedPoint is an intersection of two constructor types, Constructor<Tagged> and typeof Point. Since Constructor<Tagged> is a mixin constructor type, its construct signature is "mixed into" the constructor for Point. Thus, TaggedPoint has a single construct signature with the same parameter list as Point but with the return type Tagged & Point.

Mixin classes can constrain the types of classes they can mix into by specifying a construct signature return type in the constraint for the type parameter. For example, the following WithLocation function implements a subclass factory that adds a getLocation method to any class that satisfies the Point interface (i.e. that has x and y properties of type number).

interface Point {
    x: number;
    y: number;
}

const WithLocation = <T extends Constructor<Point>>(Base: T) =>
    class extends Base {
        getLocation(): [number, number] {
            return [this.x, this.y];
        }
    }

Fixes #4890.
Fixes #10261.

@pleerock

This comment has been minimized.

Copy link

pleerock commented Jan 30, 2017

I guess we can use multiple mixins this way?:

class Customer extends Subscribable(Scorable(Tagged(Person))) { 

Everything fits good into exist language syntax, however it also would be great to have some syntax sugar mixins, maybe even separate keyword, because this does not look good between beautiful domain models and services:

type Constructor<T> = new(...args: any[]) => T;

function Tagged<T extends Constructor<{}>>(Base: T) {
    return class extends Base {

And one another question, is it possible to do:

function Tagged<T extends Constructor<{}>>(Base: T) {
    return class extends Base {
       /// ...
    }
}

class Customer extends Tagged(Person) {
    accountBalance: number;
}

let customer = new Customer("Joe");

and later on to check if customer instanceOf Tagged ? It will be really-really useful to check it in runtime to perform some operations based on it (especially when we can't do same right now with interfaces).

@ahejlsberg

This comment has been minimized.

Copy link
Member

ahejlsberg commented Jan 30, 2017

I guess we can use multiple mixins this way?

Yes.

Everything fits good into exist language syntax, however it also would be great to have some syntax sugar mixins, maybe even separate keyword, because this does not look good between beautiful domain models and services.

We may consider adding the Constructor<T> type to lib.d.ts, but beyond that I wouldn't expect syntactic sugar for the feature.

and later on to check if someCustomer instanceOf Tagged?

You can check if something is an instance of a specific invocation of Tagged like this:

const TaggedPoint = Tagged(Point);

function check(obj: object) {
    if (obj instanceof TaggedPoint) {
        // obj narrowed to mixin type
    }
}

However, you can't use instanceof to determine if an object is some Tagged type, but you could of course write a user defined type predicate to check it.

@ahejlsberg ahejlsberg merged commit f32f95a into master Jan 30, 2017

4 checks passed

TypeScript Test Run typescript_node.4 Build finished.
Details
TypeScript Test Run typescript_node.6 Build finished.
Details
TypeScript Test Run typescript_node.stable Build finished.
Details
continuous-integration/travis-ci/pr The Travis CI build passed
Details

@ahejlsberg ahejlsberg deleted the mixinClasses branch Jan 30, 2017

@ahejlsberg ahejlsberg added this to the TypeScript 2.2 milestone Jan 30, 2017

@justinfagnani

This comment has been minimized.

Copy link

justinfagnani commented Jan 31, 2017

@ahejlsberg this is awesome, thanks for making this all work!

Since #4890 was where we were talking about the extends type operator, which would describe the mixin application more accurately than intersection types, should we open a new issue to track that, or it that idea shelved in favor of intersections indefinitely?

@justinfagnani

This comment has been minimized.

Copy link

justinfagnani commented Jan 31, 2017

@pleerock it actually is possible to get customer instanceOf Tagged working by implementing the [Symbol.hasInstance] method on Tagged, and having Tagged leave some information on the class it produces.

I talk about this here: http://justinfagnani.com/2016/01/07/enhancing-mixins-with-decorator-functions/ though some members of the V8 team have warned me against using hasInstance at all, since at least at one point it cause a global slowdown of instanceOf.

@ahejlsberg

This comment has been minimized.

Copy link
Member

ahejlsberg commented Jan 31, 2017

@justinfagnani We considered adding a B extends A type operator (or alternatively an A with B type operator), but we decided against it because we have yet to devise a consistent error reporting scheme for such an operator.

Presumably users would expect B extends A to report errors if A and B have conflicting properties (e.g. when A has a property x: number and B has a property x: string), similarly to how we report conflicts in class and interface declarations. The extends keyword strongly implies such a check. However, when A and/or B are type variables, we don't discover such errors until B extends A is instantiated with actual types. This brings about two issues. (1) Type instantiations are deferred and occur only when they are actually needed. This is pretty much a must in any compiler implementation that supports parametric types. (2) The reasons for type instantiations are not currently tracked (and, indeed, are not easy to track). Because of these issues, errors reported from type instantiations would be inconsistent and unable to exactly pinpoint their cause in the source code. For example, errors resulting from relating types that contain properties using extends types might or might not be reported depending on whether we ever need to check the particular properties (because only then would instantiation happen). So, you might see "phantom" errors that appear and disappear in inexplicable ways. That's not a good experience.

We could of course consider having an extends type operator that doesn't report errors, but that would immediately lead to inconsistencies (because a B extends A would presumably be assignable to A, but might not actually be assignable upon instantiation).

So, the upshot of all of this is that any implementation of extends we could provide would be an unacceptable compromise. For that reason we've chosen to stay with the intersection operator. And, of course, it is always good to not have multiple ways of doing almost the same thing.

@justinfagnani

This comment has been minimized.

Copy link

justinfagnani commented Jan 31, 2017

@ahejlsberg Thanks for the detailed explanation.

There were some comments about deferring the extends check until the types are known. Is something like that possible? And excuse my ignorance, I only hang out with compiler people from my days on Dart and from sitting near some V8 folk.

class A {
  x: number;
}

// 1: is it possible to infer here that Base must be extendable by {x: string}?
// extendableBy might be a new internal type operator?
const B = <T extends Constructor<{}>>(Base: T) => class extends Base {
  x: string;
}

// 2: is possible here to check that A is extendable by {x: string}?
// This is where the error would be reported
const C = B(A);

(by the way, this is why I was asking if we should open an issue, for this discussion. I can move it there, and it could be closed so I know it's over :) )

@mjewell

This comment has been minimized.

Copy link

mjewell commented Feb 6, 2017

What would the types for a function which applied a mixin look like? I couldn't find a way to get this to work:

type Constructor<T> = new(...args: any[]) => T;

interface IMixin<S extends Constructor<{}>, T> {
  (superclass: S): T; // this return type should be Constructor<T> & S?
}

function applyMixin<S extends Constructor<{}>, T>(Mixin: IMixin<S, T>, SuperClass: S) {
  return Mixin(SuperClass);
}

class SuperClass {
  x() {}
}

function Mixin<T extends Constructor<{}>>(superclass: T) {
  return class extends superclass {
    y() {}
  };
}

const A = applyMixin(Mixin, SuperClass);
const a = new A();
// a.x is not known here
// a.y is known here

const B = Mixin(SuperClass);
const b = new B();
// b.x is known here
// b.y is known here
@ahejlsberg

This comment has been minimized.

Copy link
Member

ahejlsberg commented Feb 6, 2017

@mjewell That's an example of where we need higher-order functions (#9366), i.e. the ability to pass a generic function as an argument to another generic function without erasing the type parameters of the first function.

@mjewell

This comment has been minimized.

Copy link

mjewell commented Feb 6, 2017

Thanks for the quick response. Does this also mean there is no way to return a generic class from a mixin? Something like:

class SuperClass<T> {

}

function Mixin<T extends Constructor<SuperClass>>(superclass: T) {
  return class<S> extends superclass<S> {
    y() {}
  };
}
@electricessence

This comment has been minimized.

Copy link

electricessence commented Feb 8, 2017

Wow. Is this really happening?

@mhegazy

This comment has been minimized.

Copy link
Contributor

mhegazy commented May 13, 2017

Looks like you are running into #15066.

The workaround is to give your function an explicit type annoation using intersection types, i.e.:

export default <S extends Constructor>(Superclass: S): S & Constructor<{ <props Pluggable adds>}> => class Pluggable extends Superclass { }
@khusamov

This comment has been minimized.

Copy link

khusamov commented May 15, 2017

@mhegazy Thank you! It helped, but there was the following strange problem.

File Pluggable.ts with mixin:

import AbstractPluginManager from './AbstractPluginManager';
export type Constructor<T = object> = new(...args: any[]) => T;
export interface Pluggable {
    pluginManager: AbstractPluginManager;
}
export default <S extends Constructor>(Superclass: S): Constructor<Pluggable> & S => class extends Superclass {
    private _pluginManager: AbstractPluginManager;
    get pluginManager(): AbstractPluginManager {
		return this._pluginManager ? this._pluginManager : this._pluginManager = new AbstractPluginManager(this);
	}
}

The file where this mixin is used:

import Pluggable from '../plugin/Pluggable';
export default class AbstractRequestHandler extends Pluggable(EventEmitter) { ...

The last line caused two errors:

error TS4093: 'extends' clause of exported class 'AbstractRequestHandler' refers to a type whose name cannot be referenced.
error TS4020: 'extends' clause of exported class 'AbstractRequestHandler' has or is using private name 'Pluggable'.
@DanielRosenwasser

This comment has been minimized.

Copy link
Member

DanielRosenwasser commented May 15, 2017

@khusamov looks like a bug, can you open up a separate issue?

@khusamov

This comment has been minimized.

Copy link

khusamov commented May 16, 2017

@DanielRosenwasser I open issue #15870

@dmitryxcom

This comment has been minimized.

Copy link

dmitryxcom commented May 25, 2017

I'm trying to understand why inference works better than explicit typing here.
I'm new to TS so maybe I'm missing some core understanding of what TS does with
const a: Type = ...;
but it feels like either it should work with explicit typing or there's something wrong with Constructor & T; return type (and it doesn't quite capture what's going on).

class Person {
    constructor(public name: string) {}
}

type Constructor<T> = new(...args: any[]) => T;

type MixinFn<MixinInterface> = <T extends Constructor<{}>>(superclass: T) => Constructor<MixinInterface> & T;

interface Tagable {
    tag: (tag: string) => void;
}

// Why doesn't this work?
type TagableMxinFn = MixinFn<Tagable>;
const Tagable: TagableMxinFn = <T extends Constructor<{}>>(superclass: T) => { // T is not <Constructor<{}>>
    return class extends superclass {
        // no typechecking of rerturn type; must use implements
    }
};

const TagablePerson = Tagable(Person);
let foo  = new TagablePerson('a');
// foo.tag(); error

// While this does (and you can actually drop the return type.
function Tagable2<T extends Constructor<{}>>(superclass: T) {
  return class extends superclass {
      // implements Tagable is enforced here
      tag(tag: string) {
          console.log(`tagged with ${tag}`);
      }
  }
}

const Tagable2Person = Tagable2(Person);
let bar = new Tagable2Person('s');
bar.name;
bar.tag('s'); // all good, autocompleted and checked

export {};
@DanielRosenwasser

This comment has been minimized.

Copy link
Member

DanielRosenwasser commented May 25, 2017

@dmitryxcom please open up a separate issue if you think you've found a bug. Also, that works for me using the nightlies.

@JustASquid

This comment has been minimized.

Copy link

JustASquid commented Aug 9, 2017

Just to be clear - it's flat out impossible right now to have a generic mixin, or to mix into a generic type? This is really limiting the usefulness of mixins for me, are there any workarounds?

@granteagon

This comment has been minimized.

Copy link

granteagon commented Apr 25, 2018

@zzmingo I like it but I think mixin would be better than mixins.

class SuperHero mixin Person, CanFly, SuperStrength {}
@granteagon

This comment has been minimized.

Copy link

granteagon commented Apr 25, 2018

We could also just do something like C#:

class SuperHero extends Person, CanFly, SuperStrength {}

Would work just about the same.

@justinfagnani

This comment has been minimized.

Copy link

justinfagnani commented Apr 25, 2018

@granteagon I don't think the TypeScript team wants to add new additional features outside of he type system. There's too much danger of incompatibilities with future JavaScript evolution.

FWIW, I have a proposal to TC39 to add mixins to JavaScript: https://github.com/justinfagnani/proposal-mixins

As that progresses, the TypeScript team could consider adding support. I'm not sure when they start adding proposed features, but probably not earlier than stage 3.

@granteagon

This comment has been minimized.

Copy link

granteagon commented Apr 25, 2018

@justinfagnani Mixins seem to solve a lot of inflexibility with OOP. They also somewhat bring the functional programming world and OOP world closer together. If there's a way I can show support for you proposal, let me know.

@trusktr

This comment has been minimized.

Copy link

trusktr commented Jun 17, 2018

Hello everyone, I'm trying to figure out how to type a class-factory. I've an API that takes an object literal, and effectively returns the equivalent of a class.

const Foo = Class(({Private}) => ({
  someMethod() {
    Private(this).somePrivateMethod()
  },
  private: {
    somePrivateMethod() { ... },
  }
}))

const foo = new Foo
foo.someMethod()

where Class is a function that accepts an object literal (or a function that returns an object literal) and returns what is effectively equivalent to a class {}. I am wondering how to begin typing this (if possible).

I made a StackOverflow question about it, and I figured to share it here because the people that know most about class factories in TypeScript are right here. :)

@unional

This comment has been minimized.

Copy link
Contributor

unional commented Jun 17, 2018

@trusktr Have not yet think deeply in how to solve your problem, but you might encounter this: #17388 when you create your solution. 🌷

@pleerock

This comment has been minimized.

Copy link

pleerock commented Jun 19, 2018

In my opinion one of the issues of classes in javascript is absence of mixins. People can do object merging and destructing with plain javascript objects and lot of people simply use factory functions which produce pojos and have all javascript flexibility.

Although it is possible to do mixins right now (this PR) but usage is ugly and probably is used by people on some edge case scenarios.

@trusktr

This comment has been minimized.

Copy link

trusktr commented Jun 29, 2018

I agree, using TypeScript adds a lot of complexity when trying to do stuff like this that is otherwise simple in plain JavaScript.

@granteagon

This comment has been minimized.

Copy link

granteagon commented Jun 29, 2018

Seems like we are getting off topic.

@thejohnfreeman thejohnfreeman referenced this pull request Aug 19, 2018

Closed

Allow type parameters in base class expressions #26542

4 of 4 tasks complete
@ShanonJackson

This comment has been minimized.

Copy link

ShanonJackson commented Aug 21, 2018

@trusktr there is a little known typescript type mostly because its undocumented that will allow you type that called ThisType

And all i solved the Mixin problem today in a correctly typed implementation.

The underlying problem is that the "this" of a class cannot be changed unless via a super-type or itself, this limits and solution because what you want is essentially to manipulate the type of "this" within the class. EDIT: Thought i'd intercept here but i know you can manipulate the "this" within a function but i'm talking about within classes as a whole.

Understanding that i wrote this with some help.

export type Constructor<T = {}> = new (...args: any[]) => T;

/* turns A | B | C into A & B & C */
type UnionToIntersection<U> =
	(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

/* merges constructor types - self explanitory */
type MergeConstructorTypes<T extends Array<Constructor<any>>> =
	UnionToIntersection<InstanceType<T[number]>>;

export function Mixin<T extends Array<Constructor<any>>>(constructors: T): Constructor<MergeConstructorTypes<T>> {
	const cls = class {
		state = {
		}
		constructor() {
			constructors.forEach((c: any) => {
				const oldState = this.state;
				c.apply(this);
				this.state = Object.assign({}, this.state, oldState);
			});
		}
	} as any;
	constructors.forEach((c: any) => {
		Object.assign(cls.prototype, c.prototype);
	});
	return cls as any;
}

And the implementation.....

export class FooMixin {
	state = {
		value: ""
	}
	getBlah() {
		return "Blah"
	}
}

export class BarMixin {
	state = {
		othervalue: "merge together"
	}
	getBar() {
		return "Bar"
	}
}

export class FooBar extends Mixin([FooMixin, BarMixin]) {
	constructor() {
		super();
		this.getBlah = this.getBlah.bind(this);
		this.getBar = this.getBar.bind(this);
	}

	test() {
		this.state.value  // here on the typings good :)
		this.state.othervalue // here on the typings good :) this proves the merge strategy for state
		this.getBar(); // here :)
		this.getBlah(); // here :)
	}
}

I feel like this should put this request to rest, it doesn't need special syntax or some hieroglyphics solution that hacks together over complicated typings.
take prototypes -> smash them together -> extend -> correct this.

@aleksey-bykov

This comment has been minimized.

Copy link

aleksey-bykov commented Aug 21, 2018

i can't believe that this adventure is officially called "mixins":

  • no generics
  • no way for to mix things adhoc, only through laborious class (re)declarations
  • no way to get out of single base class/prototype limitation

and all this is happening in a broad daylight when much better results can be achieved in plain JS by simply calling a bunch of initializers on a bare object

this is a notorious case of where "support idiomatic javascript code" goal is violated

so does typescript have mixins? no it does not

@admosity

This comment has been minimized.

Copy link

admosity commented Oct 12, 2018

I did an adaptation of @ShanonJackson's solution and got a pseudo form of generics working. @JustASquid @aleksey-bykov I have the same issue as you guys and this seems to be the closest thing I got to generics. This seems to be somewhat "okay" in terms of being able to have stable classes and mixing those in - instead of defining mixin functions with anonymous classes.

export type Constructor<T = {}> = new (...args: any[]) => T;

/* turns A | B | C into A & B & C */
type UnionToIntersection<U> =
	(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

/* merges constructor types - self explanatory */
type MergeConstructorTypes<T extends Array<Constructor<any>>> =
  UnionToIntersection<InstanceType<T[number]>>;


export function Mixin<C1>(ctor1: Constructor<C1>): Constructor<C1>;
export function Mixin<C1, C2>(
  ctor1: Constructor<C1>,
  ctor2: Constructor<C2>,
): Constructor<C1 & C2>;
export function Mixin<C1, C2, C3>(
  ctor1: Constructor<C1>,
  ctor2: Constructor<C2>,
  ctor3: Constructor<C3>,
): Constructor<C1 & C2 & C3>;
export function Mixin<C1, C2, C3>(
  ctor1: Constructor<C1>,
  ctor2: Constructor<C2>,
  ctor3: Constructor<C3>,
): Constructor<C1 & C2 & C3>;
export function Mixin<C1, C2, C3, C4>(
  ctor1: Constructor<C1>,
  ctor2: Constructor<C2>,
  ctor3: Constructor<C3>,
  ctor4: Constructor<C4>,
): Constructor<C1 & C2 & C3 & C4>;
export function Mixin<C1, C2, C3, C4, C5>(
  ctor1: Constructor<C1>,
  ctor2: Constructor<C2>,
  ctor3: Constructor<C3>,
  ctor4: Constructor<C4>,
  ctor5: Constructor<C5>,
): Constructor<C1 & C2 & C3 & C4 & C5>;
export function Mixin() {
  const constructors = [].slice.call(arguments);
  return constructors.reduce((cls, mixin, idx) => {
    if (!idx) {
      return mixin;
    }
    const mixedClass = class extends mixin {
      constructor(...args: any[]) {
        super(...args);
        cls.call(this, args);
      }
    };
    Object.assign(mixedClass.prototype, cls.prototype, mixin.prototype);
    return mixedClass;
  });
}

Example:

class A<T> {
  a = 1;
  b = 3;

  genericFuncFromA(some: T) {

  }
}

class B {
  c = 2;

  duck() {

  }
}

class E {
  d = 4;
  quack() {
  }
}


class C<T> extends Mixin(A, B, E) {
  something(testing: T) {
  }

};

// retype the generic classes you care about
type _C<T> = C<T> & A<T>;

const d: _C<string> = new C<string>();

All the instance variables/methods come through great:
image

The generics are somewhat working...:
image

How can I get rid of this...:
image

Can't get out of the single base limitation either. Anyone might have ideas to how this could be achieved? Maybe something crazy could be done with proxies. Edit: Could be done with Symbol.hasInstance

image

@ShanonJackson

This comment has been minimized.

Copy link

ShanonJackson commented Oct 20, 2018

its also easily possible to adapt the solution to use Objects instead of classes, or both.

@tannerntannern

This comment has been minimized.

Copy link

tannerntannern commented Oct 27, 2018

I implemented a solution to the generics problem in my ts-mixer library, but the solution can easily be adapted if you don't want to take on a (albeit, small) dependency.

Essentially, the trick is to use class decorators (which can't alter types) to apply the mixins "on the JavaScript side" in conjunction with interface merging to coerce the proper class type "on the TypeScript side." Because TypeScript is blind to changes made by class decorators, the interface merging will work without conflicts:

import {MixinDecorator} from 'ts-mixer';

// Some generic classes
class GenericClassA<T> {
	testA(input: T) {}
}
class GenericClassB<T> {
	testB(input: T) {}
}

// Class decorator/interface merging trick to create the generic mixed class
@MixinDecorator(GenericClassA, GenericClassB)
class Mixed<A, B> {
	newMethod(a: A, b: B) {}
}
interface Mixed<A, B> extends GenericClassA<A>, GenericClassB<B> {}

let mm = new Mixed<string, number>();
mm.testA('test');		// ok
// mm.testA(2);		        // will cause error

mm.testB(2);			// ok
// mm.testB('test');		// will cause error

More info on this exploit is available in the ts-mixer docs. If you prefer not to use my library, a potential MixinDecorator might look like this:

function MixinDecorator(...constructors) {
	return function<T, U extends T>(baseClass: T): U {
                // `Mixin` is assumed to come from one of the solutions above.
		return class Mixed extends Mixin(baseClass, ...constructors) {} as unknown as U;
	};
}

I'd love feedback if anyone has any. 🙂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment