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

Draft: Breaking: assoc, dissoc, and modify #37

Open
wants to merge 20 commits into
base: develop
Choose a base branch
from
Open

Conversation

Harris-Miller
Copy link
Collaborator

@Harris-Miller Harris-Miller commented May 25, 2023

Remove blanket support for object shape alteration

The previous type definition for assoc allowed any key to be added to any object. It also allowed for the value type of a key to be changed. The new definition strictly adheres to the type of object passed

type Obj = {
  str: string;
  num: number;
};

const obj: Obj = { str: 'foo', num: 1 };

// with the previous definition
const r1 = assoc('unknownKey', 'whatever', obj);
//    ^? Obj & Record<'unknownKey', string>
const r2 = assoc('num', '2', obj); // ok
//    ^? Omit<Obj, 'num'> & Record<'num', string>

// with new definition
const r3 = assoc('unknownKey', 'whatever', obj); // type error: Argument of type '"unknownKey"' is not assignable to parameter of type 'keyof Obj'
const r4 = assoc('num', '2', obj); // type error: Argument of type 'string' is not assignable to parameter of type 'number'

The reasoning behind this is simple: Type Safety. Neither of those 2 operations would be allowed when mutating the object directly

obj.unknownKey = 'whatever'; // type error: Property 'unknownKey' does not exist on type 'Obj'
obj.num = '2'; // type error: Type 'string' is not assignable to type 'number'.

And typings for assoc should not either.

Similar behavior can be seen with dissoc. The current definition allows you to remove a key from an object without issues. The new definition forbids that unless that key is marked as optional or | undefined

// with the previous definition
const r5 = dissoc('str', obj); // ok
//    ^? Omit<Obj, 'num'>

// with new definition
const r6 = dissoc('str', obj); // type error: Argument of type 'string' is not assignable to parameter of type 'never'

This is to match the behavior of the delete operation, which dissoc uses under-the-hood.

type Obj2 = {
  str: string;
  num: number;
  opt?: boolean;
  orUndefined: boolean | undefined;
  orNull: boolean | null;
}

const obj2: Obj2 = { str: 'foo', num: 1, orUndefined: true, orNull: true }

delete obj2.str; // type error: The operand of a 'delete' operator must be optional.
delete obj2.num;  // type error: The operand of a 'delete' operator must be optional.
delete obj2.opt; // ok!
delete obj2.orUndefined; // ok!
delete obj2.orNull;  // type error: The operand of a 'delete' operator must be optional.

// with new definition
dissoc('str', obj2); // type error
dissoc('num', obj2); // type error
dissoc('opt', obj2);  // ok!
dissoc('orUndefined', obj2);  // ok!
dissoc('orNull', obj2); // type error

Note that | null is not allowed either. That is intention by both delete and dissoc.

Record<string, number> type definitions are the exception here since all keys are technically optional

const rec: Record<string, number> = {};

// all of these are ok
delete rec.foo;
delete rec.bar;

dissoc('foo', rec);
dissoc('bar', rec);

I do consider this a Breaking change, because it can/should break existing usage. However, I do believe it is acceptable to do without bumping both ramda, @types/ramda, and types-ramda to 0.30.0 because it is really fixing the behavior more than anything else

If in typescript you are still looking to be able to add properties onto an object where they are not defined on said object, mergeLeft or mergeRight would be better suited for this task as that is more about creating new shapes

Similarly for dissoc, omit can be used to create a shape without the property you want

@Harris-Miller Harris-Miller changed the title Draft: Assoc Breaking: Assoc Jul 4, 2023
@Harris-Miller Harris-Miller changed the title Breaking: Assoc Breaking: assoc and dissoc Jul 4, 2023
@Harris-Miller Harris-Miller changed the title Breaking: assoc and dissoc Breaking: assoc, dissoc, and modify Jul 6, 2023
@Harris-Miller Harris-Miller changed the title Breaking: assoc, dissoc, and modify Draft: Breaking: assoc, dissoc, and modify Jul 6, 2023
@Harris-Miller Harris-Miller changed the title Draft: Breaking: assoc, dissoc, and modify Breaking: assoc, dissoc, and modify Oct 8, 2023
@Harris-Miller
Copy link
Collaborator Author

@Nicholaiii @kedashoe @adispring Please review

@kedashoe
Copy link

I like it, but ya I do imagine this would break some things for people using ramda + typescript.

Also a bit of a shame assoc and dissoc will not be "opposites". Although these are pretty weird function names to begin with. dissoc is like delProp, maybe we deprecate/change the name and add an addProp function. If assoc can no longer do the job, I would prefer anyway to have something like addProp as an option for users rather than having to use merge*.

Are adjust and update typed like these newly updated functions?

@Harris-Miller
Copy link
Collaborator Author

Harris-Miller commented Oct 11, 2023

adjust and update don't require typing updates like these because the array "keys" are just number indexes.

In modern typescript, plan objects generally serve as one of two thing: Structs or Maps;

The typing updates that I did here are to support both. With the Struct variety, you have the object shape defined ahead of time and are never really adding properties that wouldn't be defined.

The other, Map, is Record<string, any>, where you have 0-to-n keys that are unknown. The updates I made do still support using assoc and dissoc on objects defined in that manner

I've been writing typescript almost exclusively for 5 years now and in my experience, these type updates are NOT likely to break anyone's typescript code. And it's because of how the updates mimic typescript behavior for when you directly mutate an object. It is still "Breaking" behavior for the function type definitions, but I truly believe the impact will be low

addProp and delProp can both be accomplished by mergeRight and omit. Regardless, they're use case in the land of typescript would be very rare. the assumption I'm making here with addProp is that it would work like this:

type Foo = {
  foo: string;
};

const foo: Foo = { foo: '' };

assoc('bar', 'value', foo); // type-error, `'bar'` does not exist on type Foo
addProp('bar', 'value', foo); // ok, returns type `Foo & { bar: string }`

// alternatively, you can upcast to what the new type will be
type FooBar = Foo & { bar: string };
assoc('bar', 'value', foo as FooBar); // ok, returns type `FooBar`

@kedashoe
Copy link

The other, Map, is Record<string, any>, where you have 0-to-n keys that are unknown. The updates I made do still support using assoc and dissoc on objects defined in that manner

Ah I think I did not fully understand this, ty. Even though (as I think you are getting at) they have the same underlying representation in JS, they are different operations on different types. imho having addProp and delProp for this would be nice, makes code easier to read. But not necessary at this point (if ever).

👍

@Harris-Miller
Copy link
Collaborator Author

I'm thinking of throwing this MR up in a few subreddits to pull in community opinions

@Harris-Miller
Copy link
Collaborator Author

@person1123 @y-a-v-a @larssn @Morgantheplant @joeyquarters @theres-jon @kiandinyari @ACrystalC @adrogon @valentinpalkovic

Tagging all of you since you participated in #64. I wanted everyone to be aware of and ask for feedback to this MR

@Harris-Miller
Copy link
Collaborator Author

@tacomanator @akshayjai1 @klepek42 @char0n tagging you all for your participation on ramda/ramda#3415. Would like your opinion on this

@char0n
Copy link

char0n commented Oct 12, 2023

@Harris-Miller not competent enough in TypeScript to give opinion here.

@tacomanator
Copy link

tacomanator commented Oct 14, 2023

@Harris-Miller Happy to see the change. My opinion is considerably less qualified, but as I understand it builds will break and, in some cases, alternatives need to be swapped in. For this reason I personally would prefer a version bump.

@fuadsaud
Copy link

fuadsaud commented Feb 12, 2024

At first glance I find this to be a bit restricting. I find the the pattern of gradually assoc'ing new props to objects as part of a data transformation pipeline quite useful and natural. assoc feels to me almost like a special case of merge and it would feel weird to type them differently.

@Harris-Miller
Copy link
Collaborator Author

@fuadsaud

I find the the pattern of gradually assoc'ing new props to objects as part of a data transformation pipeline.

Could you private concrete example using https://www.typescriptlang.org/play?

@Nicholaiii
Copy link

@fuadsaud

I find the the pattern of gradually assoc'ing new props to objects as part of a data transformation pipeline.

Could you private concrete example using https://www.typescriptlang.org/play?

This is a common FP approach. Here's fp-ts with do, as an example

https://gcanti.github.io/fp-ts/guides/do-notation.html

@Harris-Miller
Copy link
Collaborator Author

@fuadsaud @Nicholaiii
Ok I understand now. Using one of the examples from ts-fp:

const mainDo: T.Task<{ x: string; y: string }> = pipe(
  T.Do,
  T.bind('x', () => readLine),
  T.bind('y', () => readLine),
  T.tap(({ x }) => print(x)),
  T.tap(({ y }) => print(y))
)

The way that the generic is set on T.Task<> is what I'm also looking to enforce with assoc

T.bind('x', () => readLine) and T.bind('y', () => readLine) are allowed because of { x: string; y: string }. T.bind('z', () => readLine) would not be allowed

The same should go for assoc in that assoc('x', 'foo', {} as { x: string; y: string }) and assoc('y', 'bar', {} as { x: string; y: string }) would be allowed, but not assoc('z', 'baz', {} as { x: string; y: string });

Just as T.Task<> is expecting you to work within the type set to it's generic, I'm saying you should as well with assoc. My suggested update in this MR would still allow for a flow that you're suggesting, you would just have to set the type upfront as well

A contrived example:

// acceptable now, would error after change
const fn1 = pipe<[{}]>(
  assoc('x', 'foo'),
  assoc('y', 'bar'),
); // (arg: {}) => { x: string, y: string }
fn1({}); // { x: string, y: string }

// acceptable after change
const fn2 = pipe<{ x: string, y: string }>(
  assoc('x', 'foo'),
  assoc('y', 'bar'),
); // (arg: { x: string, y: string }) => { x: string, y: string }
fn2({} as { x: string, y: string }); // { x: string, y: string }

// Using `Record<>` would also work as expected both now and after change
const fn3 = pipe<Record<string, string>>(
  assoc('x', 'foo'),
  assoc('y', 'bar'),
); // (arg: Record<string, string>) => Record<string, string>
fn3({}); // Record<string, String>

As stated in the description, I'm looking to solve a via typings a fundamental difference between javascript and typescript. While you can on any object in javascript obj.newProp = 'foobar' you can assoc('newProp', 'foobar', obj) just the same, typescript disallows that if newProp is not defined as a prop on obj. And therefore assoc shouldn't either.

Yes, javascript is a dynamic language, but is inherently unsafe as a result. Typescript does its best to add a layer of type safety, and I believe matching the expected behaviors of assoc and dissoc as described in the description of this MR adds the same level of typescript to Ramda that it deserves.

Another way to look at it is the signatures for these functions are different between javascript and typescript

javascript typescript
assoc (Idx k) => k -> v -> {k: v} -> {k: v} (Obj a) => keyof a -> a[keyof a] -> a -> a
mergeRight {k: v} -> {k: v} -> {k: v} (Obj a, Obj b) => a -> b -> a & b

assoc should always be given the same type it returns, mergeRight can intersect different types passed. This makes sense to me and I believe is correct for the intended use cases

Copy link

@Nicholaiii Nicholaiii left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is a positive change, and for a couple of reasons. First, the logic is flawed.
With this basis:

type Obj = {
  str: string;
  num: number;
};

const obj: Obj = { str: 'foo', num: 1 };

assoc is not equal to following unsafe operation:

obj.foo = 'bar'

but rather it is equivalent to this typesafe operation:

const obj2 = {
   ...obj,
   foo: 'bar'
}

It does not assign but associate, on a new, cloned object.

With these changes we are unable to do type transformation pipelines with assoc, modify or dissoc and I think that's really unhelpful. We are going to have to do either typecasting (with as) or similar much less typesafe code to compensate.

I think finally it's worth noting that the types that are output by assoc, dissoc and modify are all very typesafe already. They do not output the input type, but a modified type that reflects the changes. This is by far the most flexible, useful and true-to-the-basecode option.

(on a side-note: I think something in pipe or compose is breaking types. It seems to strip the original type and only output the modification of these)

@Nicholaiii
Copy link

Nicholaiii commented Feb 15, 2024

The first fn1 example shows exactly why it's pertinent to not merge this change. We cannot have type tranformation pipelines, which is a really useful things to be able to do.

@fuadsaud
Copy link

fuadsaud commented Feb 15, 2024

Today while writing some code following this same pattern I tried using lenses to update some values in an object (changing type) and, to my surprise, it seems to behave as proposed by this PR. Using spread works fine.

type ExternalData = {
  id: string,
  createdAt: string
}

type InternalData = {
  id: string,
  createdAt: Date
}

function internalizeWithSpread(data: ExternalData) {
  return {data, createdAt: new Date(data.createdAt)}
}

function internalizeWithLens(data: ExternalData) {
  return R.over(R.lensPath('createdAt'), (d) => new Date(d), data)
  )
}

function internalizeWithAssoc(data: ExternalData) {
  return R.assoc('createdAt', new Date(data.createdAt), data)
}

internalizeWithAssoc works fine today, but would break with the proposed change.

I agree that assoc should be compared not with assignment but with the spread/merge equivalent example above.

It does not assign but associate, on a new, cloned object.

Yes. An object can evolve over time and be transformed into new things with different shapes. I don't see a reason why evolution should be restricted to mergeLeft/mergeRight when assoc, over, etc are also valid ways to evolve shapes.

@Harris-Miller
Copy link
Collaborator Author

I think finally it's worth noting that the types that are output by assoc, dissoc and modify are all very typesafe already. They do not output the input type, but a modified type that reflects the changes. This is by far the most flexible, useful and true-to-the-basecode option.

I believe this to be half-true. Let me run you both through my thought processes here. Let's start with a type and a function:

type Temp = {
  scale: string;
  value: number;
}

function isFreezing(temp: { scale: string; value: number}) {
  let rtn = false;
  switch (temp.scale) {
    case 'celsius': rtn = temp.value <= 0;
    case 'farenheit': rtn = temp.value <= 32;
    case 'kelvin': rtn = temp.value <= 273.1;
    // no default, assume exhaustive
  }
  return rtn;
}

assoc, dissoc, and modify are all typesafe in their input vs output, this part is absolutely true

const original: Temp = {
  scale: 'fahrenheit',
  value: 15
};

const updated = R.dissoc('value', original);
//    ^? Omit<Temp, 'value'>

isFreezing(updated); // error !

However, they are not when it comes to their arguments

function toC(temp: Temp) {
  if (temp.scale == 'fahrenheit') {
    // misspelled 'scale'! no warning from typescript
    const next =  R.assoc('scal', 'celsius', temp);
    return R.modify('value', t => (t - 32) * 5/9, next);
  }
  if (temp.scale == 'kelvin') {
    const next = R.assoc('scale', 'celsius', temp);
    return R.modify('value', t => t - 273.15, next);
  }

  // already celsius, return input
  return temp;
}
const toCheck = toC(original);
//    ^? Temp & Record<'valeu', string>

isFreezing(toCheck); // no error! `Temp & Record<'valeu', string>` satisfies `Temp`
// ->

The imperative mutable equivalent catches the misspelling

function mutToC(temp: Temp) {
  if (temp.scale == 'fahrenheit') {
    temp.scale = 'celsius';
    temp.value = (temp.valeu - 32) * 5/9; // misspelling caught by type checker
    return temp;
  }
  if (temp.scale == 'kelvin') {
    temp.scale = 'celsius';
    temp.value = temp.value - 273.15;
    return temp;
  }

  // already celsius, return input
  return temp;
}

The changes in this MR would fix the issue above. And weighing this case against the transformation pipeline use case, I believe that this holds more value for the typing of these functions for ramda

function internalizeWithAssoc(data: ExternalData): InternalData {
  // required to cast here is more verbose,
  // but it has the benefit of also showing the intention of changing the type from `ExternalData` to `InternalData`
  return R.assoc('createdAt', new Date(data.createdAt), data as InternalData)
}

// IMO though, `evolve` would be a more appropriate function for this operation in general
function internalizeWithEvolve(data: ExternalData): InternalData {
  return R.evolve({ createdAt: (x: string) => new Date(x) }, data);
};

While it's true that you can also do this with Lenses, the fact that you can is simply due to javascript dynamic types. It really doesn't mean that you should. It also does not match the type for it

function internalizeWithLens(data: ExternalData) {
  // The type for `over` is `Lens s a → (a → a) → s → s`
  // however this operation is `Lens s a → (a → b) → s → Omit<s, ...> & Record<..., b>
  return R.over(R.lensPath('createdAt'), (d) => new Date(d), data)
  )
}

// same for R.modify
function internalizeWithModify(data: ExternalData) {
  // The type for `modify` is `Idx → (v → v) → {k: v} → {k: v}`
  // however this operation is `Idx → (v → z) → {k: v} → {k: z}`
  return R.modify('createdAt', (d) => new Date(d), data)
  )
}

IMO, using R.over or R.modify to do type evolution is out-of-scope of their intended purposes, especially in Typescript. So adding in the type restrictions proposed in this MR would be a net positive

assoc or modify are arguably more in scope in this way, there are examples of both prop add/removing and type changing of existing props in the docs. However, from a Typescript perspective, the idea of adding a new prop is more about adding a prop that is optional and missing. The example for assoc on the ramda docs is:

R.assoc('c', 3, {a: 1, b: 2}); //=> {a: 1, b: 2, c: 3}

Expanding this out a bit give you

const original = {a: 1, b: 2};
const next = R.assoc('c', 3, original); //=> {a: 1, b: 2, c: 3}

Adding types

type Obj = { a : number; b: number; c?: number }; // c? optional
const original: Obj = {a: 1, b: 2};
const next = R.assoc('c', 3, original); //=> {a: 1, b: 2, c: 3}

It's not that I believe assoc, modify, and dissoc should never be used for transformation pipelines. I just believe that that use case should require an "opt-in" experience in typescript.

Assignment vs Association

Yes, this is true. assoc() is functionally equivalent to spread+set prop. But I believe assoc is primarily used to "immutably assign". The added benefit of being more restrictive against key matching and value types to prevent unintentional human errors IMO outweighs the opposing argument to keep it the way it is that allows type evolution by default

We can go back and forth on this all we want. Let's agree to disagree.

This MR is 8 months old now. I haven't merged it because of how Breaking it is. And I also never got a chance to follow through with the greater community on this. We are only 3 people, and ramda has 11 Million weekly downloads, and almost 900k repos in gitlab. Polling for data will need to be an important step in moving forward on this MR

I've personally never used assoc, modify, or dissoc in the transformation pipelines. My example above and the fp-ts Task example pointed out are both very contrived. A large, real-world example of some transformation pipelines would be helpful for me to better understand what I'm maybe missing here

Thank you

@Harris-Miller
Copy link
Collaborator Author

@Nicholaiii

(on a side-note: I think something in pipe or compose is breaking types. It seems to strip the original type and only output the modification of these)

Can you provide an example of this?

@Nicholaiii
Copy link

I think finally it's worth noting that the types that are output by assoc, dissoc and modify are all very typesafe already. They do not output the input type, but a modified type that reflects the changes. This is by far the most flexible, useful and true-to-the-basecode option.

I believe this to be half-true. Let me run you both through my thought processes here. Let's start with a type and a function:

type Temp = {
  scale: string;
  value: number;
}

function isFreezing(temp: { scale: string; value: number}) {
  let rtn = false;
  switch (temp.scale) {
    case 'celsius': rtn = temp.value <= 0;
    case 'farenheit': rtn = temp.value <= 32;
    case 'kelvin': rtn = temp.value <= 273.1;
    // no default, assume exhaustive
  }
  return rtn;
}

assoc, dissoc, and modify are all typesafe in their input vs output, this part is absolutely true

const original: Temp = {
  scale: 'fahrenheit',
  value: 15
};

const updated = R.dissoc('value', original);
//    ^? Omit<Temp, 'value'>

isFreezing(updated); // error !

However, they are not when it comes to their arguments

function toC(temp: Temp) {
  if (temp.scale == 'fahrenheit') {
    // misspelled 'scale'! no warning from typescript
    const next =  R.assoc('scal', 'celsius', temp);
    return R.modify('value', t => (t - 32) * 5/9, next);
  }
  if (temp.scale == 'kelvin') {
    const next = R.assoc('scale', 'celsius', temp);
    return R.modify('value', t => t - 273.15, next);
  }

  // already celsius, return input
  return temp;
}
const toCheck = toC(original);
//    ^? Temp & Record<'valeu', string>

isFreezing(toCheck); // no error! `Temp & Record<'valeu', string>` satisfies `Temp`
// ->

The imperative mutable equivalent catches the misspelling

function mutToC(temp: Temp) {
  if (temp.scale == 'fahrenheit') {
    temp.scale = 'celsius';
    temp.value = (temp.valeu - 32) * 5/9; // misspelling caught by type checker
    return temp;
  }
  if (temp.scale == 'kelvin') {
    temp.scale = 'celsius';
    temp.value = temp.value - 273.15;
    return temp;
  }

  // already celsius, return input
  return temp;
}

The changes in this MR would fix the issue above. And weighing this case against the transformation pipeline use case, I believe that this holds more value for the typing of these functions for ramda

function internalizeWithAssoc(data: ExternalData): InternalData {
  // required to cast here is more verbose,
  // but it has the benefit of also showing the intention of changing the type from `ExternalData` to `InternalData`
  return R.assoc('createdAt', new Date(data.createdAt), data as InternalData)
}

// IMO though, `evolve` would be a more appropriate function for this operation in general
function internalizeWithEvolve(data: ExternalData): InternalData {
  return R.evolve({ createdAt: (x: string) => new Date(x) }, data);
};

While it's true that you can also do this with Lenses, the fact that you can is simply due to javascript dynamic types. It really doesn't mean that you should. It also does not match the type for it

function internalizeWithLens(data: ExternalData) {
  // The type for `over` is `Lens s a → (a → a) → s → s`
  // however this operation is `Lens s a → (a → b) → s → Omit<s, ...> & Record<..., b>
  return R.over(R.lensPath('createdAt'), (d) => new Date(d), data)
  )
}

// same for R.modify
function internalizeWithModify(data: ExternalData) {
  // The type for `modify` is `Idx → (v → v) → {k: v} → {k: v}`
  // however this operation is `Idx → (v → z) → {k: v} → {k: z}`
  return R.modify('createdAt', (d) => new Date(d), data)
  )
}

IMO, using R.over or R.modify to do type evolution is out-of-scope of their intended purposes, especially in Typescript. So adding in the type restrictions proposed in this MR would be a net positive

assoc or modify are arguably more in scope in this way, there are examples of both prop add/removing and type changing of existing props in the docs. However, from a Typescript perspective, the idea of adding a new prop is more about adding a prop that is optional and missing. The example for assoc on the ramda docs is:

R.assoc('c', 3, {a: 1, b: 2}); //=> {a: 1, b: 2, c: 3}

Expanding this out a bit give you

const original = {a: 1, b: 2};
const next = R.assoc('c', 3, original); //=> {a: 1, b: 2, c: 3}

Adding types

type Obj = { a : number; b: number; c?: number }; // c? optional
const original: Obj = {a: 1, b: 2};
const next = R.assoc('c', 3, original); //=> {a: 1, b: 2, c: 3}

It's not that I believe assoc, modify, and dissoc should never be used for transformation pipelines. I just believe that that use case should require an "opt-in" experience in typescript.

Assignment vs Association

Yes, this is true. assoc() is functionally equivalent to spread+set prop. But I believe assoc is primarily used to "immutably assign". The added benefit of being more restrictive against key matching and value types to prevent unintentional human errors IMO outweighs the opposing argument to keep it the way it is that allows type evolution by default

We can go back and forth on this all we want. Let's agree to disagree.

This MR is 8 months old now. I haven't merged it because of how Breaking it is. And I also never got a chance to follow through with the greater community on this. We are only 3 people, and ramda has 11 Million weekly downloads, and almost 900k repos in gitlab. Polling for data will need to be an important step in moving forward on this MR

I've personally never used assoc, modify, or dissoc in the transformation pipelines. My example above and the fp-ts Task example pointed out are both very contrived. A large, real-world example of some transformation pipelines would be helpful for me to better understand what I'm maybe missing here

Thank you

There are a lot of points to address here, but let me start with the first: you misunderstand the fp-ts example if you think it backs your case up.
In fp-ts, unlike Ramda, pipe starts with a value and the return takes no parameters. The value it starts with here is Do, which functionally is equivalent to {}. The type passed to Tasks generic holds only what it expects as the output of pipe. The input is an empty object that is then transformed to fit the output type. Bind does not expect the input value to conform to the shape of the generic passed. If you check Chain.ts you'll see the signature is the same as assoc currently is (apart from also supporting higher-kinded types).

I understand the plea for your intermediate usecases, and I both admire and respect that you didn't merge, but I think maybe a new function (eg assign and assignWith) that doesn't widen is a better candidate to champion.

@Nicholaiii
Copy link

@fuadsaud
Copy link

I was hit by a similar situation yet another time 😅 :

captureException(error, { extra: dissoc('error', object) })

In this code, object is a generic object that could contain any keys. The only requirement I have is: I want to transform object so that it doesn't contain an error key. I could do something like 'error' in object ? dissoc('error', object) : object but that feels overly defensive and for no good reason.

@Harris-Miller
Copy link
Collaborator Author

Harris-Miller commented Feb 20, 2024

@fuadsaud

I was hit by a similar situation yet another time 😅 :

captureException(error, { extra: dissoc('error', object) })
In this code, object is a generic object that could contain any keys. The only requirement I have is: I want to transform object so that it doesn't contain an error key. I could do something like 'error' in object ? dissoc('error', object) : object but that feels overly defensive and for no good reason.

This case would still be covered by my proposed changes if object was typed to be similar to Obj here:

type Obj = {
  foo: string;
  bar: number;
  error?: Error;
}

error?: Error being the important bit there. Would still work as expected

@fuadsaud
Copy link

@Harris-Miller what if you don't control the type of object? In my particular case, object comes from a third-party library (pino). It is, by design, an open object that could contain any key of any type (like an open record).

Semantically, I'm accepting an open record, don't want to make any judgements about it, and just want to make sure that I'm passing it forward without a certain key.

Of course I could narrow the type down and do some acrobatics to conform (e.g. check if prop is in the object), but that again increases the verbosity of the code without any particular real gain.

And this is actually the behavior already observed in the released version of types/ramda with dissoc/omit. Especially with R.omit, it feels natural that it should map to TS's Omit.

@Harris-Miller
Copy link
Collaborator Author

@fuadsaud

Semantically, I'm accepting an open record, don't want to make any judgements about it, and just want to make sure that I'm passing it forward without a certain key.

Of course I could narrow the type down and do some acrobatics to conform (e.g. check if prop is in the object), but that again increases the verbosity of the code without any particular real gain.

And this is actually the behavior already observed in the released version of types/ramda with dissoc/omit. Especially with R.omit, it feels natural that it should map to TS's Omit.

With this MR, assoc, dissoc, and modify would continue to work against an object of type Record<> as it does currently does today. I call this out in the MR description

const rec: Record<string, number> = {};

// all of these are ok
delete rec.foo;
delete rec.bar;

// therefor, so are these
dissoc('foo', rec);
dissoc('bar', rec);

The type Record<string, number> is saying "I have an object of 0-to-n key: string/value: number pairs. Using assoc/dissoc to add/remove keys fits the type definition. Because the keys 'foo' and 'bar' are part of the Set string. What I want to change is to disallow when a a key used with assoc/dissoc/modify is NOT part of that Set, eg for type Foobar = { foo: string; bar: string }, dissoc('baz', obj) should error because baz is not part of the Set 'foo' | 'bar'.

Note that for an object typed as Record<string, string>, while any key would be allowed, value type not part of the value Set would be disallowed in the same way. rg assoc('foo', 1, obj); would error if obj was as Record<string, string>. 1 is number, not a string. I'm calling this out specifically as it relates to the ExternalData and InternalData examples you gave in previous comments

@fuadsaud
Copy link

@Harris-Miller got it. The case I mentioned last also works for Record<string, unknown> but it doesn't work for objects. I believe I should file a separate issue to discuss it since it's not exactly related to this proposal.

@Harris-Miller
Copy link
Collaborator Author

@fuadsaud

got it. The case I mentioned last also works for Record<string, unknown> but it doesn't work for objects. I believe I should file a separate issue to discuss it since it's not exactly related to this proposal.

Yes please do fill out a separate issue

As a general note about javascript objects and typescript (well, my opinion, really). We generally use them as either a hash or as Map.

Before the native Map existed, we just objects for this case, which can be best represented in typescript by Record<>

While in a lot of cases, Map would be more appropriate, from my personal experience I know that we have to continue using plain-objects (eg Map is not supported by structuredClone, libraries like redux has poor support for them because of the inherent mutable nature of Map as a container to key/value pairs)

The reason why I bring this up is while the hash usage is more of a struct, the map usage is more of a container. And the container variety can be treated as a Functor

const strColl: Record<string, string> = {};

const intColl: Record<string, number> = R.map(x => Number.parseInt(x, 10), coll);

^^ This is just fmap, eg here

A plain-object serving as a hash, is not a Functor (eg like your ExternalData/InternalData examples above). Although both are plain-objects

It makes it really hard to type functions that except objects are arguments when it's near impossible to tell what the use case is going to be and how users intend to use the functions provided by ramda

@Harris-Miller Harris-Miller changed the title Breaking: assoc, dissoc, and modify Draft: Breaking: assoc, dissoc, and modify Feb 22, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants