Skip to content
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

Suggestion: Abstract/Opaque types #15408

Closed
jonaskello opened this issue Apr 27, 2017 · 38 comments
Closed

Suggestion: Abstract/Opaque types #15408

jonaskello opened this issue Apr 27, 2017 · 38 comments

Comments

@jonaskello
Copy link

jonaskello commented Apr 27, 2017

When using a functional programming style it is common to have a module that contains a record type, constructor functions for that record type and other function that operate on that type. For example consider a simple module for a date record:

date.ts

export interface Date { 
    readonly day : number,
    readonly month : number,
    readonly year : number
};

export function createDate(day: number, month: number, year: number): Date {
    return {day, month, year};
}

export function diffDays(date1: Date, date2: Date): number {
 // Imagine implementation that calculates 
 // the difference in days between date1 and date2
}

// Extract the year from the date
export function year(date: Date): number {
    return date.year;
}

This works well to start with. However we may at some point want to refactor the structure of the Date type. Let's say we decide it is better to have it store ticks since epoch:

export interface Date { 
    readonly ticks : number,
};

The problem is now that other modules may have directly read the Date.years property instead of going through our year() function. So other modules are now directly coupled to the type's structure, making our refactoring hard because we need to go through and change all calling modules instead of just changing our date module. (In real-world scenarios the record type is more complex and perhaps nested).

The Ocaml programming language has a nice solution for this called abstract types (see docs here, under the heading "Abstract Types".

The idea is that we export the fact that there is a Date type and you have to use that type to call our functions. However we do not export the structure of the Date type. The structure is only known within our date.ts module.

A suggestion for the syntax could be:

date.ts

export abstract interface Date { 
    readonly day : number,
    readonly month : number,
    readonly year : number
};

other.ts

Import { Date, createDate, year } from "./date"

const date = createDate(2017, 1, 1);
const a = year(date); // OK
const b = date.year; // ERROR

Maybe typescript already has a construct for achieving something similar but my research have not found any.

@jingyu9575
Copy link

date.year can be a function call, see Accessors.

export interface Date { 
    readonly day : number,
    readonly month : number,
    readonly year : number
};

class TickDate implements Date {
    constructor(private tick: number) { }

    get day() { return this.tick /* ... */ ; }
    get month() { return this.tick /* ... */ ; }
    get year() { return this.tick /* ... */ ; }
}

const date: Date = new TickDate(1234)

@jonaskello
Copy link
Author

jonaskello commented Apr 27, 2017

Yes that's a good point. class is certainly the right way to achieve encapsulation if you are doing OO style programming. However class is not a good fit for FP style. In FP your building blocks are immutable types (data records) and pure functions. As Javascript is multi-paradigm (OO and FP) I think it would make sense for typescript to support FP style encapsulation too.

Other than the fact that classis a bad fit for FP, there are practical reasons why you would prefer pure data objects over classes. One being that they are easy to serialize to JSON. Another that they are easy to deep compare. Also duck typing works for data objects but not classes. They are also easy to merge etc.

@aluanhaddad
Copy link
Contributor

Just FYI, accessors are also available for object literals

export function createDate(): Date {
  return {
    get year() { ... },
    get month() { ... },
    get day() { ... }
  };
}

I guess my question is, if callers should not have been consuming year, why was it exposed?

@jonaskello
Copy link
Author

jonaskello commented Apr 27, 2017

year is exposed because I need to access it within the module and typescript does not allow any method of hiding it outside the module while still keeping it a plain immutable data record. Hence the feature request :-). For the purpose of FP style, I want to work with only plain immutable data records (types with constructors). There is a good example of how abstract types are used in the Ocaml docs I linked above. They are also mentioned here in a talk about reason which is an Ocaml dialect by facebook with syntax more similar to typescript.

Could you elaborate on how I would use accessors with plain objects? How does the typing look for Date in your example above and what would be the implementation in the { ... } be? Would the accessors not be functions/methods? And would not the accessors be available to other modules, so everyone would know the shape of the Date type?

@aluanhaddad
Copy link
Contributor

aluanhaddad commented Apr 27, 2017

@jonaskello that is correct, the accessors would be methods. Such get accessors imply nothing about the structure of the underlying value and can simply return values from the enclosing lexical scope (no need for this).

This does mean that other modules would know the shape of Date but I am not sure how this can be avoided in a structural type system. Well Ocaml is structurally typed...

@jonaskello
Copy link
Author

jonaskello commented Apr 27, 2017

I think you have a good point about not being able to hide the structure in a structural type system. What is needed for abstract types would probably be more akin to nominal typing. However I don't think a full nominal type system is needed just to get abstract types?

I made an example which tries to emulate abstract types. As you can see it breaks down because of structural typing.

EDIT: I managed to update the example so it to some degree emulates nominal typing by using a string literal type as a tag. This may actually be good enough but still it would be nice to have proper support for abstract types.

date.ts

export interface Date {
    readonly type: "ReallySecretDate"
};

interface DateContent {
    readonly day: number,
    readonly month: number,
    readonly year: number
};

type InternalDate = Date & DateContent;

export function createDate(day: number, month: number, year: number): Date {
    const theDate: InternalDate = { type: "ReallySecretDate", day, month, year };
    return theDate;
}

export function diffYears(date1: Date, date2: Date): number {
    const date1_internal = date1 as InternalDate;
    const date2_internal = date2 as InternalDate;
    return date1_internal.year - date2_internal.year;
}

// Extract the year from the date
export function year(date: Date): number {
    const date_internal = date as InternalDate;
    return date_internal.year;
}

consumer.ts

import { createDate, diffYears, year } from "./date";

const date1 = createDate(2017, 1, 1);
const date2 = createDate(2018, 1, 1);

// const year1 = date1.year; // Error - good
const year2 = year(date1); // OK - good
const diff = diffYears(date1, date2); // good

// const year1 = year({}); // Error - good
// const diff2 = diffYears({}, {}); // Error - good

const year1 = year({type: "ReallySecretDate"}); // No Error - not good but maybe good enough
const diff2 = diffYears({type: "ReallySecretDate"}, {type: "ReallySecretDate"}); //  No Error - not good but maybe good enough

@jonaskello
Copy link
Author

So I think one conclusion is that two abstract types would need to be considered different even if their structure is the same. This would probably create a simple nominal typing system. So in that sense it may be related to #202.

@jonaskello
Copy link
Author

I haven't used Elm myself but it seems to have an abstract type concept, only it is called opaque types. I also think Elm uses structural typing. I just mention it as an example of a language with both structural typing and abstract/opaque types.

@jonaskello
Copy link
Author

Calling the types "opaque" may be better than "abstract" since that is an overloaded term already considering abstract classes. Also considering the distinct keyword from this proposal for nominal typing, I would revise my syntax proposal for abstract/opaque types to:

export distinct opaque interface Date { 
    readonly day : number,
    readonly month : number,
    readonly year : number
};

Or perhaps distinct is already implied by opaque and could be left out.

@falsandtru
Copy link
Contributor

class ADate {
    static day(date: ADate) {
        return date.day;
    }
    constructor(
        private readonly day: number
    ) {
    }
}

new ADate(1).day // error
const { day } = ADate;
day(new ADate(1)) // 1

@gcnew
Copy link
Contributor

gcnew commented Apr 28, 2017

As @falsandtru said, the trick is to use private.

The following pattern is a good out of the box emulation.

Date.ts

class Date {
    public constructor(
        private readonly day: number,
        private readonly month: number,
        private readonly year: number
    ) {
    }

    // You can make the constructor private and use a static creator
    // function if you prefer it
    static createDate(day: number, month: number, year: number) {
        return new Date(day, month, year);
    }

    static year(date: Date) {
        return date.year;
    }

    static diffYears(date1: Date, date2: Date): number {
        return date1.year - date2.year;
    }
};

export { Date };

export const createDate = Date.createDate;

export const year = Date.year;
export const diffYears = Date.diffYears;

test.ts

import { Date, createDate, year, diffYears } from './Date'

const y2017 = createDate(1, 1, 2017);

// It's ok to use "pojo" Dates as we don't rely on any "methods"
// or hidden class being present
const pojoYear: Date = JSON.parse('{ "year": 2000, "month": 1, "day": 1 }');

y2017.year // error
diffYears(y2017, pojoYear); // 17, as expected
diffYears(y2017, { year: 2000, month: 1, day: 1 }); // error

Note that you don't use classes as OOP classes. You just use the visibility primitives they provide. Such a usage is more of a "namespace" or a module-like dictionary, thus I think it's perfectly inline with the functional style.

@jonaskello
Copy link
Author

jonaskello commented Apr 28, 2017

@falsandtru That's an interesting approach :-). However now we have two levels of encapsulation, the module level and the class level. I think for FP style we want the module to be the only level and not use class at all (I actually ban class with linting rules). Also the module level encapsulation we cannot git rid of so if one has to go it would have to be the class level.

@jonaskello
Copy link
Author

@gcnew privatemight be a good idea, if it could be applied without using class, maybe something like this:

export interface Date { 
    private readonly day : number,
    private readonly month : number,
    private readonly year : number
};

@gcnew
Copy link
Contributor

gcnew commented Apr 28, 2017

@jonaskello Why does class bother you?

@falsandtru
Copy link
Contributor

Private constructor and properties hide behaviors of classes.

class ADate {
    static date(day: number) {
        return new ADate(day);
    }
    static day(date: ADate) {
        return date.day;
    }
    private constructor(
        private readonly day: number
    ) {
    }
}

new ADate(1) // error
const { date, day } = ADate;
date(1) // ADate
date(1).day // error
day(date(1)) // 1

@jonaskello
Copy link
Author

@gcnew class is a OOP concept, and while you can emulate FP style with it by not using some parts of it, that has some disadvantages.

  1. Double encapsulation as mentioned above.
  2. You get a much more verbose syntax compared to using just plain object types.
  3. It's hard to enforce the use of class as FP emulation across a team of developers. Banning class altogether is easy.

I guess the reverse question could be posed. If the goal of opaque types can be achieved with simple typing of plain objects, then why would you want to use class?

@jonaskello
Copy link
Author

It's kind of parallel to the use of readonly in plain object types. You could make a class with private fields that expose only getters. But that is a lot of work, so why do that instead of having readonly typing on plain objects?

@gcnew
Copy link
Contributor

gcnew commented Apr 28, 2017

@jonaskello There is a good, working solution. You disregard it, because you want it to look like a specific implementation in a specific language. If you want to apply FP concepts, you have to apply the gist of them. Splitting hairs over syntax is vanity IMHO.

@jonaskello
Copy link
Author

jonaskello commented Apr 28, 2017

@gc The emitted JS for a class is very different from that of a plain object (especially in ES5). If it was purely a syntactic difference in TS then the emitted JS would be the same? Using readonly on plain objects is a pure syntatic difference in TS because the emitted JS does not care about it. Point being that class adds un-needed weight to the emitted JS. The proposed opaque types would achieve the goal without affecting the emitted JS. it just lives in the typing world, same as readonly.

@gcnew
Copy link
Contributor

gcnew commented Apr 28, 2017

@jonaskello Why do you care about bytecode? The important characteristics are the behavioural and runtime characteristics, not the emitted code. Using a class can actually be better for performance as it's obvious for the JS engine to JIT compile it.

Anyway, if you are really allergic to (runtime) classes, you can use the following functions for boxing/unboxing. The class solution is much more ergonomic and type-safe, though.

class OpaqueDate {
    private __opaque_date_brand: any;
}

function createOpaqueDate(day: number, month: number, year: number) {
    return { day, month, year } as any as OpaqueDate;
}

// private! don't export this
function unwrap(date: OpaqueDate): { day: number, month: number, year: number } {
    return date as any;
}

PS: In the "class" case, you are not bound to constructing class instances. Did you see the JSON.parse example? You can make the constructor private and use you own creator function, that returns POJO data. What's important is the shape at runtime and the type at compile time.

@jonaskello
Copy link
Author

jonaskello commented Apr 28, 2017

@gcnew My concerns are not about the "byte-code", although I can see how you may think that from the way I presented it. Let me try again.

Imagine that we decide to do FP style programming in plain JS. Now JS is a multi-paradigm language so let's decide on a sub-set of the language that fits our style. For FP style we need functions, modules and records. Now lets map those concepts to JS. Functions and modules are simple to map, JS has the same concepts. Records is a bit harder but the closest thing would be Object. I think we could agree that class would not add value here. It has no advantages over Object (ie. it is not immutable or has data-hiding). But it is more complex, so it would add complexity without adding any value. So the natural mapping for record is Object. So now we have our FP sub-set of JS which is functions, modules and object. Lets write the code:

date.js

/**
* @param {number} day
* @param {number} month
* @param {number} year
* @returns {Date}
* NOTE: Return type is to be considered immutable and opaque.
*/
export function createDate(day, month, year) {
    return { day, month, year };
}

/**
* @param {Date} date 
* @returns {number}
*/
export function year(date) {
    return date.year;
}

That was simple and nice! But as we can see we needed to add some annotations in the comments to make it clear what we expect from the consumer. Now the great thing about typescript is that it can help you verify these annotations, without forcing you to write your JS code different. So in TS, for all @param, and @returns annotations we just add type annotations. For the "is to be considered immutable" part we just add a typing with readonly. I really, really like how elegantly readonly enforces our annotation without forcing us to change the original code. Now for the "is to be considered opaque" annotation typescript does not have a good way of expressing that yet.

We could of course re-write our JS code to use class in typescript as per your suggestion. But IMO that is not what typescript is about. It's about transforming what you normally write as comments/annotations into type assertions. Without forcing you to use other parts of JS than you normally would.

@gcnew
Copy link
Contributor

gcnew commented Apr 28, 2017

Let's take a step back. What is the first and topmost goal? To have a specific functionality or to achieve it in a specific way?

If we subscribe to the first goal - to have a nominal type with hidden state, then we have the means to do that. In TypeScript it's done by a class with private fields. You'd say that this changes the way you would write things - well maybe, but you get the benefit of type checking. Languages often have specific "ways" to do things called "patterns".

Is the class approach non-javascripty? No, it's not! The resulting JavaScript is completely valid and even indistinguishable when exported from a module. Is it functional? Yes, it is! No mutation is made and no implicit class state is passed around. The only effect is that the functions are packed up in an extra namespace, however they can be exported separately as shown in my first example, thus nullifing the argument about nested namespaces.

Now let's consider the case where you want to achieve it using only the tools that you consider best fitting - objects and functions. One of the founding principles of JavaScript is that it relies on duck-typing heavily. All properties on an object are effectively public from JS-es point of view. That's why object interfaces allow public properties only. If you want to hide state for real, then you should use closures. If you go on with that approach, you have to change your API to be "object" based, not function-accepting-data based.

In my opinion there are many options and you have a lot of flexibility in designing your API. But sometimes not every approach works with every combination of expressions. In that specific case I think the power the language provides is enough, but you are fixating on a specific implementation too much. You can even go with a convention based approach - prefix private fields with an underscore and write a lint rule to warn against using them outside of their defining module.

Edit: I understand that you have banned some expression level syntax. But this is a conscious choice that has the side effect of banning the benefits it provides. Personally I'm against private/hidden fields in interfaces as interfaces traditionally express what should be available, not implementation details. As you noted, you can get some mileage with nominal types, but the hiding part will still be absent. You'd have to explicitly cast it for boxing/unboxing. Considering that this could already be achieved by a class that you simply use as a type (like in my second comment), isn't that good enough?

@jonaskello
Copy link
Author

Yes and if you want immutability you can solve it by using classes or closures, or conventions like immutable properties start with underscore. Or you can solve it in the type system with readonly. Do you think typescript did the wrong thing in adding readonly to plain objects? I think we have different opinions here. You seem to be prefer to use class in different ways to express things that I think would be easier to express in the type system.

@gcnew
Copy link
Contributor

gcnew commented Apr 28, 2017

I added an "Edit" section.

@gcnew
Copy link
Contributor

gcnew commented Apr 28, 2017

I think readonly is broken in its current implementation. I personally don't use it - it's just syntactic noise. And I don't think objects should have "hidden" properties in their interface, as this doesn't reflect the runtime semantics.

@jonaskello
Copy link
Author

jonaskello commented Apr 28, 2017

I think maybe you are reading too much semantic meaning into the keyword interface in typescript. I agree that an "Interface" in general terms should be something public that is exposed. And I can understand that if you have a background in OOP you think that is what it should be because in OOP it is exactly that. You have a class and it can implement an interface that is the public part.

However you need to realise that in FP style the keyword interface is used only for expressing types. FP style uses ADT. So it has nothing to do with "Interface" in the OOP sense. I actually prefer the type keyword which is semantically better:

export type Date = { 
    readonly day: number,
    readonly month: number,
    readonly year: number
}

Could you elaborate on how the readonly implementation is broken? I find it most useful to express immutability at the type system level.

@gcnew
Copy link
Contributor

gcnew commented Apr 28, 2017

Don't get me wrong. I'm not an advocate of OOP, quite the opposite actually. I personally use classes only when they are the only option and I still think how I can avoid them. However the way currently JS works is not with hidden object state. TypeScript has made a deviation in classes and I would prefer that it doesn't do any more free-will choices (namespaces, enums, private, protected, three-slash references are already enough). On the other hand, mindfully using these features where appropriate is not a deadly sin.

On readonly: #13002, #13347

@jonaskello jonaskello changed the title Suggestion: Abstract types Suggestion: Abstract/Opaque types Apr 28, 2017
@gcnew
Copy link
Contributor

gcnew commented Apr 28, 2017

I don't see what the differences between ADTs and interfaces have to do with the problem at hand. In traditional languages both are nominal and both precisely state what the programmer can access. ADTs have the benefit that they may be one of several options, while interfaces describe a single shape. In the language that I'm familiar with - Haskell, if you export a data type and it's value constructors, you can pattern match on them any way you want.

Your ask seems to preclude two things - nominal types and a mechanism to export only the data type without its constructors (if we follow ADT's analogy).

Now, lets get a step back again. We can already achieve this behaviour with an alternative mechanism. Why not use it? After all, languages are different, the idea is what matters :)

PS: The module itself is an implicit static class. If we were able to make our static class the export object itself than there would be no difference between the two concepts.

@jonaskello
Copy link
Author

jonaskello commented Apr 28, 2017

Ok, checked #13002, #13347 but still think readonly is really useful in practice, and those issues are more about splitting hairs :-). Imagine having to make all of your redux state objects as class instead of plain objects in order to make it immutable. That to me is syntactic noise.

Regarding hidden state, I think JS objects neither hides or exposes their state as you don't know the type of the object. So for me, an object in JS has "undetermined" state which may be specified by comments/annotations.

Edit: The module is actually an implicit instance of an Object and can be typed as such if you import it with import * as Date. I sometimes use this if I have several modules that are "plug-ins". I can import them with star syntax and get objects that adhere to the same interface. So there is already no difference between the Object and module concept. Only class is different.

@jonaskello
Copy link
Author

I just wanted to point out that in typescript's ADT system, the types has no interface construct in the OOP sense even if typescripts calls its type declarations interface instead of type sometimes. They are just types. So in FP style, I think we should do what you describe. Export types and constructors at the module level. So the "interface" in the sense of exposing things is on the module (and this interface is unrelated to the type declarations done with interface keyword). I think this maps nicely to ES6 modules and exports. Wrapping things in another layer with a class just adds syntactic noise. ES6 requires you to have modules but you can skip the classes. We already have module and exports in ES6, why not use them for encapsulation?

Yes, you are absolutely correct, I want nominal typing in order to support exporting of opaque types.

Regarding why not use class for opaque types, I think I already addressed that, please see my list of reasons above.

@jonaskello
Copy link
Author

I guess I could use the same type of arguments as Douglas Crockford in this video. Having two ways of doing the same thing is "clutter" in the language. We have two ways to encapsulate things, module and class. Considering FP style only we only need one way. We cannot get rid of module so class has to go. "Class does not spark joy" :-).

@mhegazy
Copy link
Contributor

mhegazy commented Apr 28, 2017

So this proposal is basically #5228 for all types and not just classes?

@jonaskello
Copy link
Author

@mhegazy From the above discussion I think we can boil this proposal down to privacy within a module. #5228 seems to be about privacy within a package.

The core of this proposal is that you can declare a type within a module and specify that the properties of that type are only visible to code within that module. To clarify, when I mention "module" I'm talking about what is sometimes referred to as an "ES6 module".

@jonaskello
Copy link
Author

I realised that nominal typing is not needed for opaque types and the notion of module level privacy probably fits better into what typescript already are doing.

So I made #15465 to better reflect the current state of this proposal.

@jonaskello
Copy link
Author

jonaskello commented Apr 29, 2017

@gcnew from above regarding the class approach:

The resulting JavaScript is completely valid and even indistinguishable when exported from a module.

I don't think this is true at all. Classes are very different from objects in JS, they are not indistinguishable to the consumer. For example:

  • To construct a class instance you use new. Objects are constructed via literals or are returned from constructor functions.
  • Classes does not live through a JSON.stringify(), JSON.parse() cycle but objects do. The consumer needs to apply special handling of classes for serialization.

EDIT: I ran through your class emulation example above with JSON.parse() and that actually works. So if you take special care you can get away with using class to emulate POJO. However that would require that we ban some parts of class syntax to not get into trouble. To me that is not very idiomatic JS, so I'm not convinced we should be doing that. The JS example I provided above is much more idiomatic IMO and also requires a lot less code than your example. Keep in mind that in JS object members are not public. In JS, what is public on an object is specified by docs, not types. This is why you are recommended to use the docs and not the code as reference when creating definition files for existing JS libs. So I still don't see why you are against having the type system enforce the docs of idiomatic JS. I would actually argue that having TS forcing you to write unidiomatic JS is worse than TS adding the "free-will" things you mention.

@jonaskello
Copy link
Author

Just to illustrate what I mean by unidiomatic JS, lets look at the class workaround example from above in plain JS:

date.js

class Date {
    public constructor(day, month, year) {
        this.day = day;
        this.month = month;
        this.year = year;
    }

    static createDate(day, month, year) {
        return new Date(day, month, year);
    }

    static year(date: Date) {
        return date.year;
    }

    static diffYears(date1: Date, date2: Date): number {
        return date1.year - date2.year;
    }
};

export { Date };

export const createDate = Date.createDate;
export const year = Date.year;
export const diffYears = Date.diffYears;

If we were to put that code into a JS codebase I think we would get more than one question about what we were thinking.

@gcnew
Copy link
Contributor

gcnew commented Apr 29, 2017

Keep in mind that in JS object members are not public. In JS, what is public on an object is specified by docs, not types.

@jonaskello I actually disagree with that. It might not be true for every developer, but when I'm writing in JS I consider everything that I can access a part of the public interface, unless there is an obvious convention that it's not - e.g. underscores, reserved prefixed names, etc. In my experience that is the unwritten rule people use. That's why it's standard to rely on duck-typing, keys iteration, enumeration, augmentation and monkey patching.

I agree that writing type-safe code may alter the way you would have written it otherwise. But that's the name of the game. You reap benefits in return.

I could use Crockford's arguments as well. There is an already existing way to do what you ask for (albeit a workaround), so why add a new one? My stance is that with such "type level" hiding you are hurting JS users and creating discrepancy between JS and TS - not a good thing. If you care for compatibility either wait for an official native private or use conventions obvious for everyone (for which you wouldn't even need any changes to the type-system). TypeScript has already made deviations, lets keep them to a minimum.

@jonaskello
Copy link
Author

jonaskello commented Apr 29, 2017

Yes I definitely think we disagree on this. For me the big selling point of TS is that it is idiomatic JS with types to express your constraints. You seem to look at TS more like Dart, Elm or some other compile-to-JS language where you write things different than in JS and don't care about the emitted code. For me, being able to write idiomatic JS and having a tight relationship between what I write in TS and what is emitted in JS are the big selling points and the reason I use TS. That and the easy integration into existing JS libs. I'm not sure why you would choose to use TS over other compile-to-JS languages with the stance you are taking.

We also seem to disagree on what creates discrepancies between TS and JS. I think TS forcing you to write things different than in idiomatic JS causes discrepancy. You seem to think that having the TS type system express things that are not available as native constructs in JS, but instead are conventionally expressed in docs, are causing discrepancies.

Your use of Crockford's argument is certainly valid if we regard TS and JS as two disparate languages, but not so if we regard one as the superset of the other.

I guess discussing further with so different views on the basics above would probably not be constructive. Anyway, I enjoyed the discussion and your input is much appreciated. It helped shape my suggestion in #15465 and I will continue to think about your way of viewing this.

@microsoft microsoft locked and limited conversation to collaborators Jun 14, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants