-
Notifications
You must be signed in to change notification settings - Fork 12.6k
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
Comments
date.year can be a function call, see Accessors.
|
Yes that's a good point. Other than the fact that |
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 |
Could you elaborate on how I would use accessors with plain objects? How does the typing look for |
@jonaskello that is correct, the accessors would be methods. Such This does mean that other modules would know the shape of |
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 |
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. |
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. |
Calling the types "opaque" may be better than "abstract" since that is an overloaded term already considering abstract classes. Also considering the export distinct opaque interface Date {
readonly day : number,
readonly month : number,
readonly year : number
}; Or perhaps |
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 |
As @falsandtru said, the trick is to use 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. |
@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 |
@gcnew export interface Date {
private readonly day : number,
private readonly month : number,
private readonly year : number
}; |
@jonaskello Why does |
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 |
@gcnew
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? |
It's kind of parallel to the use of |
@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. |
@gc The emitted JS for a |
@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 |
@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 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 We could of course re-write our JS code to use |
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 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? |
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 |
I added an "Edit" section. |
I think |
I think maybe you are reading too much semantic meaning into the keyword However you need to realise that in FP style the keyword export type Date = {
readonly day: number,
readonly month: number,
readonly year: number
} Could you elaborate on how the |
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. |
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. |
Ok, checked #13002, #13347 but still think 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 |
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 Yes, you are absolutely correct, I want nominal typing in order to support exporting of opaque types. Regarding why not use |
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" :-). |
So this proposal is basically #5228 for all types and not just classes? |
@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". |
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. |
@gcnew from above regarding the class approach:
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:
EDIT: I ran through your |
Just to illustrate what I mean by unidiomatic JS, lets look at the 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. |
@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. |
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. |
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
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:The problem is now that other modules may have directly read the
Date.years
property instead of going through ouryear()
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 theDate
type. The structure is only known within ourdate.ts
module.A suggestion for the syntax could be:
date.ts
other.ts
Maybe typescript already has a construct for achieving something similar but my research have not found any.
The text was updated successfully, but these errors were encountered: