Skip to content

Conversation

@Harris-Miller
Copy link
Collaborator

This update of the types for prop corrects what I consider a major flaw in the previous typing

The previous version used a utility type Prop that is defined in util/tools.d.ts What it essentially did was ignore unions with undefined, such that

({ a: string } | undefined)['a'] // error
Prop<{ a: string } | undefined, 'a'> // string

This effectively side-steps the type safety that typescript gives us

Consider the following:

type Todo = { description: string; isDone: boolean };

const todos: Todo[] = [/* ... */];

const firstActiveTodo = todo.find(todo => !todo.isDone);
//    ^? Todo | undefined

// the previous implementation of `prop` worked this way
const description = prop('description', firstActiveTodo);
//    ^? string

// the new implementation will type error here because `'description'` is not prop of `undefined`
const description = prop('description', firstActiveTodo);

The above type safety is a key part of typescript and it is important to force an opt-out off it for convenience

There are 3 ways to get around this. First would be to have strictNullChecks: false in your tsconfig. This will make functions such as .find() not return with the undefined union.

The other 2 would be done in code:

// check for undefined
if (isNotNil(firstActiveTodo) {
  // ^^ this narrows the type from `Todo | undefined` to `Todo`
  return prop('description', firstActiveTodo)
}

// or for a less safe way, use the non-null assertion operator
const description = prop('description', firstActiveTodo!);
//    ^? string

Using the ! non-null assertion operator is effectively an inline way to opt-in to the previous behavior

When using compose/pipe, users may have to make updates such as this:

const findFirstDescription = pipe(
  find((todo: Todo) => todo.isDone),
  prop('description') // used to work, now errors
);

const findFirstDescription = pipe(
  (todos: Todo[]) => find(todo => todo.isDone, todos)!,
  prop('description') // works because of non-null assertion above
);

While I am calling this change Breaking. We have to keep our minor version locked with ramda. So it will be a regular release

@Harris-Miller Harris-Miller force-pushed the prop branch 2 times, most recently from d6a7ce4 to e6d81b1 Compare May 28, 2023 00:57
Comment on lines 19 to 20
// all numbers work as keys, no guarantee it will return a value
expectType<number>(prop(10)([1, 2, 3, 4]));
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this the behavior we want here? Should this return number | undefined instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes. It should be number | undefined...

Although, I am thinking of removing array support altogether. nth already does this exact behavior and while prop does support arrays, it defers to nth internally

IMHO I feel that support is there for convenience. Typescript's stricter behavior should force users to strictly use prop or nth

path on the other hand, should continue to support both

This, however, would limit the support of the function in typescript only, and is something we should get community import on

New definition would look like this:

// prop(key)(obj)
export function prop<K extends PropertyKey>(prop: K extends Placeholder ? never : K): <U extends Record<K, any>>(obj: U) => U[K];
// prop(__, obj)(key)
export function prop<U>(__: Placeholder, obj: U): <K extends keyof U>(prop: K) => U[K];
// prop(key, obj)
export function prop<K extends keyof U, U>(prop: K, obj: U extends any[] ? never : U): U[K];

Copy link
Contributor

Choose a reason for hiding this comment

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

I suppose we could add support for arrays as separate definitions. Instead of K extends keyof U it would be K extends number

types/prop.d.ts Outdated
export function prop<_, P extends keyof never>(p: P): <T>(value: T) => Prop<T, P>;
export function prop<V>(p: keyof never): (value: unknown) => V;
// prop(key)(obj)
export function prop<K extends PropertyKey>(prop: K extends Placeholder ? never : K): <U extends any[] | Record<K, any>>(obj: U) => U extends (infer T)[] ? T : U extends Record<K, any> ? U[K] : never;
Copy link
Contributor

Choose a reason for hiding this comment

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

Is PropertyKey a built in type that is equivalent to string | number | symbol?

I can't find any official documentation on this, but found a 3rd party explanation.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

documentation FTW!

@Harris-Miller Harris-Miller changed the title Breaking: prop Draft: Breaking: prop Jun 14, 2023
@Harris-Miller
Copy link
Collaborator Author

Harris-Miller commented Jul 4, 2023

I ended up removing support for arrays. The ramda documentation does has the usage with an array as an example, but the maybe type def Idx → {s: a} → a | Undefined is what makes the most sense to support here. It's rare in typescript to not be very explicit in the fact that you're working with objects versus arrays, and thus makes more sense to type props of objects, and nth for arrays

@Harris-Miller Harris-Miller changed the title Draft: Breaking: prop Breaking: prop Jul 4, 2023
@Harris-Miller Harris-Miller merged commit ede169a into develop Jul 8, 2023
@Harris-Miller Harris-Miller deleted the prop branch July 8, 2023 18:17
@perrin4869
Copy link

I think the type change here is not correct.
Take the docs, you have an example: R.prop('x', {});.
Unfortunately, with this change, prop('x') returns a function <U extends Record<"x", unknown>>(u: U) => U["x"] , which requires an object with a required property "x", disallowing {}.

@Harris-Miller
Copy link
Collaborator Author

@perrin4869 That is intentional. It's the difference between working in javascript and typescript. In javascript we don't really care that the prop isn't "defined" on the object. We just try and access it and it returns undefined. The behavior of prop is the same as if you tried to access the prop directly

{}.x // undefined
R.prop('x', {}); //undefined

But try that in typescript and you get a type error

{}.x // Cannot find name 'x'

By disallowing an obj that does not contain a key matching the string literal passed to prop, we get that same type-safety.

The real problem this fixes is misspelling a prop. A problem that has led to thousands of lost hours by frontend devs back when all we had was notepad++

//let's say you had this object
type Person = {
  name: string;
  birthdate: Date;
};

// if you misspelled birthday, typescript would let you know
({} as Person).bithdate // Property 'bithdate' does not exist on type 'Person'.

// we want the same thing to happen when using `prop`
prop('bithdate', obj as Person);

See this playground: https://tsplay.dev/NnLKBW

@perrin4869
Copy link

hm... what if it is an optional property?

type A = {
  foo?: string
}

const a: A = {};
console.log(a.foo);

This is valid typescript. However console.log(prop("foo")(a)); fails with an error:

    Argument of type 'A' is not assignable to parameter of type 'Record<"foo", unknown>'.
      Property 'foo' is optional in type 'A' but required in type 'Record<"foo", unknown>'.

@Harris-Miller
Copy link
Collaborator Author

That's a bug! MR for fix: #75

@perrin4869
Copy link

Thanks!! That's awesome 😁

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.

4 participants