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

Integration with type systems #9

Closed
littledan opened this issue Apr 4, 2019 · 36 comments
Closed

Integration with type systems #9

littledan opened this issue Apr 4, 2019 · 36 comments
Labels
question Further information is requested
Milestone

Comments

@littledan
Copy link
Member

This proposal sounds like it'll be pretty great for type systems like TypeScript, since it's analogous to ordinary objects. Developers may also want to declare an argument type @const, which I think should also work well. We should bring type system maintainers in the loop early and see how this works out, to verify these assumptions.

@rricard
Copy link
Member

rricard commented Apr 4, 2019

Yes, I can also try to do an additional document like the transpiler design doc

@mheiber
Copy link
Contributor

mheiber commented Aug 14, 2019

@rbuckton thoughts welcome. Based on feedback re TS and Flow (for example) a good place to record our findings would be: https://github.com/rricard/proposal-const-value-types/blob/master/TypeSystem-Strategy.md

Also curious about what we can do with regard to the name conflict with the "Record" type in the TS standard library.

@mheiber
Copy link
Contributor

mheiber commented Aug 20, 2019

cc @DanielRosenwasser

@mheiber
Copy link
Contributor

mheiber commented Aug 28, 2019

@rickbutton raised a concern about TS supporting ordering mattering for keys in Records.

Not an expert, but here's my stab at discussing this topic:

It seems TS could handle this in a way similar to how type-level Tuples (distinct from const object tuples) are order-dependent currently. const object Records could be like Tuple Types with additional restrictions (the names of the keys).

@mheiber
Copy link
Contributor

mheiber commented Aug 28, 2019

related: #15

@rricard rricard added the question Further information is requested label Dec 23, 2019
@rricard rricard added this to the stage 3 milestone May 28, 2020
@Jack-Works
Copy link
Member

@mheiber the line above is 404 now, where is the new link I can see the integration of type system?

rickbutton raised a concern about TS supporting ordering mattering for keys in Records.

Now the records is sorted by key, does it no longer be a problem?

@mheiber
Copy link
Contributor

mheiber commented Jul 25, 2020

Good point, sounds like it's not a problem!

@Kingwl
Copy link
Member

Kingwl commented Jul 31, 2020

Also curious about what we can do with regard to the name conflict with the "Record" type in the TS standard library.

Worried about that too.

@DanielRosenwasser
Copy link
Member

😬

@ckknight
Copy link

Also curious about what we can do with regard to the name conflict with the "Record" type in the TS standard library.

It'd be tough to change it to match the immutable semantics of records within this proposal. It might be cleanest to introduce ReadonlyRecord<K, V> which would ostensibly the same as Readonly<Record<K, V>>.

Given that tuples defined in this proposal fit the ReadonlyArray<T> interface, it seems like that would fit in appropriately.

@noppa
Copy link

noppa commented Jul 31, 2020

The Tuples in this proposal are quite different from TS ReadonlyArrays actually. Tuples have methods that normal arrays don't (pushed etc.) and don't allow other than primitive values inside. Repurposing ReadonlyArrays to mean Tuples would be a major breaking change that I don't think they'll want to make.

@Kingwl
Copy link
Member

Kingwl commented Aug 3, 2020

Related microsoft/TypeScript#39831

@Aprillion
Copy link

Aprillion commented Sep 11, 2020

I hope the inferred values from a record of widening types won't be widened, e.g. in TypeScript:

const a = 'a'  // widening type 'a'
const b = {a}  // type {a: string}
b.a            // type string

const c = #{a} // type record (or something)
c.a            // I hope for type 'a', not string!

@DanielRosenwasser
Copy link
Member

There is no need to do literal type widening on immutable bindings. For example, no base primitive type widening happens in the following.

let x = { foo: 100 } as const;

@Jack-Works
Copy link
Member

@Aprillion But that (not widening) would be inconvenient.

function join<T, Q extends T>(a: T, b: Q) {
    if (Array.isArray(a) && Array.isArray(b)) return [...a, ...b]
    if (typeof a === 'object') return { ...a, ...b }
    return #{...a, ...b}
}
join([1, 2, 3], [4, 5, 6])
join({ a: 1 }, { a: 2, b: 2 })
join(#{ a: 1 }, #{ a: 2 })

If R&T is not widened, TypeScript would report

Argument of type '#{ a: 2; }' is not assignable to parameter of type '#{ a: 1; }'.
  Types of property 'a' are incompatible.
    Type '2' is not assignable to type '1'.ts(2345)

And that's not good.

@Aprillion
Copy link

Aprillion commented Sep 11, 2020

@Jack-Works I don't agree - I want a compiler error in exactly that situation (when I try to modify an immutable property)!
I can already join 2 objects that both have an id - with a record, I want to be forced to rename them to something like testId, resultId.

Though a TS compiler error on line with #{...a, ...b} would be nicer if the type of a and b share some keys (not sure if I would want a JS TypeError on runtime, that could be very inconvenient indeed => probably only in strict mode in TS)...

The workaround for false positive is really simple #{{...a, ...b}} or Record({...a, ...b}) if you explicitly want to overwrite mutable properties and then create an immutable record.

@Jack-Works
Copy link
Member

I want a compiler error in exactly that situation (when I try to modify an immutable property)!

Ok, readonly is not equal to exact value ("a" in the type system). TS of course will error you when you try to modify the property on a record. But make it concrete type doesn't stop it being modified.

const a: { b: 1 } = { b: 1 }
a.b = 1 // This is OK (re-assignment)
const a2: { readonly b: 1 } = { b: 1 }
a.b = 1 // Nope

All properties on the records are readonly, but that doesn't mean it should be treated as a non-widened value.

(Non-widened value on const is OK cause record is primitive and TypeScript already not widen the primitive type for const declarations).

Let me use another example:

let x = #{ a: 1 }
x.a // should be number, not 1

@Aprillion
Copy link

Aprillion commented Sep 11, 2020

let x = #{ a: 1 }
x.a // should be number, not 1

In that scenario, the widening should be applied to the mutable variable x, not to the property a of the immutable value (the record created by record literal #{a: 1}).

And IMHO it should produce compile error because x can be undefined => x?.a should be necessary here (but I am not sure how it behaves today for objects, I would not change it).

@Jack-Works
Copy link
Member

That's not how TypeScript type inferring works today.

When you type let x = { a: 1 }, x has type { a: number }. For the record, when you type let x = #{a: 1}, it should have type #{a: number}. Then when you try to assign #{} onto it, TypeScript will ban it.

Therefore, x.a is actually runtime safe, no need for x?.a

@Aprillion
Copy link

Aprillion commented Sep 11, 2020

I see your point about backwards compatibility, but please note that TS does not produce runtime safe code (due to various constructs that aren't analyzed by TS), e.g. the following playground:

let x = {a: {b: 1}}
x = JSON.parse('{}')
x.a.b

Function arguments inferred from a default value might need widening too,, however I am not sure how much the whole immutability feature would be useful without the type narrowing like following:

const start = #{type: 'start'}
const success = #{type: 'success', value: 3}

type Action = typeof start | typeof success              // non-widened types needed here
const initialState = Record({isLoading: true, value: 0}) // widened types needed here

export default function reducer(state = initialState, action: Action) {
  switch(action.type) {
    case 'success':
      return {isLoading: false, value: action.value} // OK: 3 can be assigned to number
    default: {
      const _forgottenCase: never = action           // error: #{type: 'start'} cannot be assigned to never
      return state
    }
  }
}

Currently, some ceremony is needed to make the code work, we have to either use an enum or const assertions like following:

const start = {type: 'start' as const}

Which can be fragile, because a single widening string in the union type (or in 1 element of an array) will make the whole inferred type too wide for any future type narrowing and I was hoping that immutable types will make the immutable scenarios easier, not exactly as complicated as the current immutable-like work with regular objects.

@Aprillion
Copy link

@Jack-Works perhaps I just mis-understood your original comment and my scenario would work with all the current semantics of type widening - in which case I don't propose any changes to TS type widening, just wanted to make sure I won't have to write const .. = .. as const for R&T to enable type narrowing of union types.

@nicolo-ribaudo
Copy link
Member

Would you expect to be able to pass #{type: 'success', value: 4} to your reducer? (Note the different value)

@Aprillion
Copy link

Not for that particular Action type of course, simple 3 as number would be needed (the opposite of the 3 as const use case from the current mutable world). Or a more complex scenario without any assertions:

const actionCreators = #{
  start: () => #{type: 'start'},
  success: (value: number) => #{type: 'success', value},
}
type Action = ReturnType<typeof actionCreators[keyof typeof actionCreators]>

@Kingwl
Copy link
Member

Kingwl commented Feb 23, 2021

When you type let x = { a: 1 }, x has type { a: number }.

We may need something like as mutable .

@Jack-Works
Copy link
Member

Working on this. Please watch microsoft/TypeScript#45546

@acutmore
Copy link
Collaborator

acutmore commented Dec 6, 2021

The Tuples in this proposal are quite different from TS ReadonlyArrays actually. Tuples have methods that normal arrays don't (pushed etc.) and don't allow other than primitive values inside. Repurposing ReadonlyArrays to mean Tuples would be a major breaking change that I don't think they'll want to make.

Compared to July 2020, the different methods names (pushed etc) have been dropped in favour of having the methods in Tuple.prototype to be a subset of those in Array.prototype. .

Even still Tuple<T> and ReadonlyArray<T> may have subtly different TS interfaces not assignable to each-other.

interface Tuple<T extends Primitive> {
 at(index: number): T;
 map<U extends Primitive>(callbackfn: (value: T, index: number, tuple: Tuple<T>) => U, thisArg?: any): Tuple<U>;
}

interface ReadonlyArray<T> {
 at(index: number): T;
 map<U>(callbackfn: (value: T, index: number, array: ReadonlyArray<T>) => U, thisArg?: any): Array<U>;
}

TS playground

EDIT: actually ReadonlyArray<Primitive> would be assignable to Tuple<Primitive>, but not the other way around TS playground

@nicolo-ribaudo
Copy link
Member

nicolo-ribaudo commented Dec 6, 2021

TS should be fixed to disallow assigning ReadonlyArray<Primitive> to Tuple<Primitive>: they have the same prototype, but Tuple<Primitive> is a primitive. It's like assigning let a: "" = Object.create(String.prototype).

@acutmore
Copy link
Collaborator

acutmore commented Dec 6, 2021

Yep, I would imagine a full TS implementation would implement a proper tuple type that is a true primitive

@mhofman
Copy link
Member

mhofman commented Dec 6, 2021

but Tuple<Primitive> is a primitive

I was imagining that Tuple<Primitive> would represent the object instance, not the primitive value, and that there would be a tuple primitive to represent the type of the primitive value, similar to String and string.

Actually taking the example of string template literals, we could have #[number, string] be a sub-type of tuple.

@nicolo-ribaudo
Copy link
Member

Oh right, true!

@acutmore
Copy link
Collaborator

acutmore commented Dec 7, 2021

I was imagining that Tuple<Primitive> would represent the object instance, not the primitive value, and that there would be a tuple primitive to represent the type of the primitive value

And because of how primitives auto-wrap on access TypeScript effectively makes the primitive types subtypes of their object wrappers. 'hello' and string are assignable to both String and { readonly length: number }, but not the other way around.

So the types #[number] and tuple would likely also be assignable to Tuple and { readonly length: number }.

Playground

Actually taking the example of string template literals, we could have #[number, string] be a sub-type of tuple.

Might also want to have generic primitives too, which would be a new thing (usual convention of generics is to start with a capital letter). i.e. tuple<P> as an equivalent of #[...P[]]. Though objectPlaceholder<T> is probably the more compelling use case.

type State = #{
  lastUpdatedMs: number;
  loggedInUsers: objectPlaceholder<ReadonlySet<ReadonlyUser>>;
  messages: #[...string[]];
}

I can see generic utility functions wanting to accept both Arrays and Tuples as input to specify Tuple as their argument instead of Tuple | ReadonlyArray because TS wouldn't be able to resolve methods like .map on the union type.

Playground

@mhofman
Copy link
Member

mhofman commented Dec 7, 2021

Might also want to have generic primitives too, which would be a new thing (usual convention of generics is to start with a capital letter). i.e. tuple<P> as an equivalent of #[...P[]]. Though objectPlaceholder<T> is probably the more compelling use case.

Yes I considered that too, but I wasn't sure how much of a departure from the existing primitive constraints this would be. I also agree it would be most useful for object placeholder since that primitive doesn't have a syntax notation to use instead.

@rbuckton
Copy link

rbuckton commented Dec 8, 2021

The way TypeScript handles this for a TS "tuple" is that we essentially create an intersection type. For example:

// the following are approximately the same
type T1 = [number, number];
type T2 = Array<number> & {
  "0": number;
  "1": number;
  length: 2;
};

The key difference is that we preserve the "tupleness" of T1 for a number of type operations. I would expect we would end up doing something similar, but with a primitive tuple type on the left of the intersection. The big question is how to define the box type. While I'm not opposed to introducing generics for primitive types (I was even considering that as an option for static typing for struct proposal), I'm not sure I'd want to introduce it for a single type. Instead, I was thinking about having a box type operator instead (we can bikeshed the type operator name later, but for the following examples I'll just use box). Here's a brief outline of what I'm thinking:

type T1 = tuple; // primitive base constraint for a JS tuple
type T2 = record; // primitive base constraint for a JS record
type T3 = box Type; // primitive base constraint for a JS box
type T4 = primitive; // possible new intrinsic type containing all primitives, i.e.:
                     // string | symbol | number | bigint | boolean | record | tuple | box unknown | null | undefined

// Apparent type for a primitive `tuple` (similar to how `String` is the apparent type for a primitive `string`)
interface ReadonlyTuple {
  readonly [index: number]: primitive;
  readonly length: number;
  // ... other prototype methods for a tuple
}

// Apparent type for a primitive `record`
interface ReadonlyRecord {
  // ... prototype methods for a record (if any)
}

// Apparent type for a primitive `box`
interface Box {
  // ... prototype methods for a box
  unbox(): unknown;
}

// Built-in JS tuple type:
type T5 = #[string, number]; 

// Approximation of JS tuple type using intersection:
type T6 = tuple & {
  readonly "0": string;
  readonly "1": number;
  length: 2;
};

// Built-in JS record type:
type T7 = #{ x: number, y: number };

// Approximation of JS record type using intersection:
type T8 = record & {
  readonly x: number;
  readonly y: number;
};

// Built-in JS box type:
type T9 = box Date;

// Approximation of a JS box type using intersection:
type T10 = (box unknown) & {
  unbox(): Date;
};

@mhofman
Copy link
Member

mhofman commented Dec 8, 2021

@rbuckton the box Type stuff is interesting. The current direction is to not have unbox be a prototype method, but be a static method on the constructor. As long as the following typing is possible, it should be fine to not use a generic primitive type:

interface ObjectPlaceholderConstructor {
  <T>(obj: T): placeholder T;
  getObject<T>(value: placeholder T): T;
}

var ObjectPlaceholder: ObjectPlaceholderConstructor;

It does awfully look like generics though.

@rbuckton
Copy link

rbuckton commented Dec 8, 2021

It does awfully look like generics though.

It is generic, but more like keyof T as opposed to Array<T>.

There are other things we need to consider as well, such as an approximation of readonly number[] (where the element type is known, but the length is not). My current thinking is to use something like #[...number[]]. It's a bit to type but reads better to me than #number[] or number#[], both of which seem like they're veering into syntactic space best left untouched (with #foo looking like a private name, and number #[] being a possible ASI hazard).

@rricard
Copy link
Member

rricard commented Jul 7, 2022

Closing this issue in favor of discussing on the typescript repo here: microsoft/TypeScript#49243

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests