Skip to content

My solutions #11

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

Closed
wants to merge 17 commits into from
Closed

Conversation

danvk
Copy link

@danvk danvk commented May 7, 2020

First off, amazing work! I'd been thinking of creating homework / exercises / problem sets for Effective TypeScript, but given the work you've put into this I think I'll just be linking to yours instead.

Feedback on specific issues below, but some high level comments:

  • As others have pointed out, in many cases the tests are looser than they should be and allow incorrect or imprecise types through. It's just as important to reject incorrect types as it is to accept correct ones. A tool like tsd could help with assertions here, but the new @ts-expect-error assertion (introduced in TS 3.9) might be your best bet. It produces an error unless the next line has a type error. So:
function square(x: any) {
  return x * x;
}

// @ts-expect-error
square('ten');

will fail unless you pick a parameter type precise enough to exclude ten.

  • Especially in the earlier exercises, I sometimes had a hard time figuring out what you were looking for in a solution until I saw which article you linked to as a hint. There are often many ways to do things in TypeScript (user-defined type guards, in tests, tagged unions) and some more guidance about which you were trying to teach might have been helpful.

  • Unless I missed it, I don't think tagged unions are ever explicitly introduced. Exercise 3 has a type field but keeps the user-defined type guards (isAdmin, isUser) which are completely unnecessary when you have a tag. Given how pervasive tagged unions are as a construct in TS, it would be useful to have an exercise that forces the user to narrow a type based on its tag:

    if (person.type === 'admin') {
        additionalInformation = person.role;
    }
    if (person.type === 'user') {
        additionalInformation = person.occupation;
    }

instead of:

    if (isAdmin(person)) {
        additionalInformation = person.role;
    }
    if (isUser(person)) {
        additionalInformation = person.occupation;
    }
  • It might be useful to have some more exercises around type inference, i.e. when is TypeScript able to infer types through map / filter / etc and when isn't it? Given how pervasive lodash is, it might also be useful to have some examples using that.

I'll leave more specific comments on each exercise.

Copy link
Author

@danvk danvk left a comment

Choose a reason for hiding this comment

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

Feedback on exercises 1–9, I'll leave more later!

@@ -0,0 +1,5 @@
{
"files.exclude": {
"**/node_modules": false,
Copy link
Author

Choose a reason for hiding this comment

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

In exercise 10, it took me a bit to figure out that node_modules/str-utils/index.js referred to exercises/exercise-10/node_modules, not the top-level node_modules. Adding this to my vscode settings made it easier to find.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Would you be able to make a pull-request with this change?

@@ -64,8 +64,8 @@ const persons: Person[] = [
];

function logPerson(person: Person) {
let additionalInformation: string;
Copy link
Author

Choose a reason for hiding this comment

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

It's interesting to note that this annotation is not needed. See this post for details.

@@ -95,9 +95,10 @@ function logPerson(person: Person) {
console.log(` - ${chalk.green(person.name)}, ${person.age}, ${additionalInformation}`);
}

function filterUsers(persons: Person[], criteria: User): User[] {
type Criteria = Partial<Omit<User, 'type'>>;
Copy link
Author

Choose a reason for hiding this comment

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

I found the hint to use mapped types a bit confusing given that you don't need them.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Partial and Omit are implemented using mapping. You can either use them directly or write a mapped type yourselves.

@@ -64,11 +64,18 @@ function logPerson(person: Person) {
);
}

function filterPersons(persons: Person[], personType: string, criteria: unknown): unknown[] {
function getObjectKeys<T extends object>(o: T): (keyof T)[] {
Copy link
Author

Choose a reason for hiding this comment

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

The instructions for this are:

Implement a function getObjectKeys() which returns proper type
for any argument given, so that you don't need to cast it.

"More convenient", sure, but "proper" is too strong here, string[] really is the correct type for the reasons outlined in this StackOverflow answer and this TS issue.

There's also a discussion of this in Effective TypeScript (Item 54). I'm happy to get you a free copy if you'd like.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Happy to receive a copy if you can issue it to me.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Feel free to make a pull-request with this re-phrasing.

@@ -78,6 +85,11 @@ function filterPersons(persons: Person[], personType: string, criteria: unknown)
let usersOfAge23: User[] = filterPersons(persons, 'user', { age: 23 });
let adminsOfAge23: Admin[] = filterPersons(persons, 'admin', { age: 23 });

// @ts-ignore
Copy link
Author

Choose a reason for hiding this comment

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

This would be a great use of @ts-expect-error: filtering to admins shouldn't allow you to select by occupation.

// @ts-ignore
let astronautAdmins: Admin[] = filterPersons(persons, 'admin', { occupation: 'Astronaut' });

let peopleOfAge23: Person[] = filterPersons(persons, Math.random() < 0.5 ? 'user' : 'admin', {age: 23});
Copy link
Author

Choose a reason for hiding this comment

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

This call results in an error with the overloaded definitions, even though it is guaranteed to be valid. This is one of the problems with overloads, and it's a reason to prefer using conditional types. Rewriting this function to use conditional types instead of an overload is much harder but might be a good exercise for later.

You can learn more about why conditional types are preferred in this fantastic artsy post or Item 50 (Prefer Conditional Types to Overloaded Declarations) in Effective TypeScript.

@@ -41,7 +41,7 @@ interface Admin {
role: string;
}

type PowerUser = unknown;
type PowerUser = Omit<User, 'type'> & Omit<Admin, 'type'> & {type: 'powerUser'};
Copy link
Author

Choose a reason for hiding this comment

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

It wasn't clear to me what you were supposed to do in this exercise if you didn't do the "higher difficulty" version.

Copy link
Collaborator

Choose a reason for hiding this comment

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

In the normal difficulty version you manually write PowerUser type without using neither User and Admin types.

Copy link

Choose a reason for hiding this comment

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

👍 The solution in the 'solutions' branch didn't work for me with TypeScript v3.9.7; I got multiple errors. The solution in this PR did work for me.

@@ -71,8 +71,16 @@ type ApiResponse<T> = (
}
);

function promisify(arg: unknown): unknown {
return null;
function promisify<T>(fn: (callback: (arg: ApiResponse<T>) => void) => void): () => Promise<T> {
Copy link
Author

@danvk danvk May 7, 2020

Choose a reason for hiding this comment

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

I think this is a great problem (implement promisify). The instructions could be more clear that you're expected to wrap errors in an Error object.

Also unclear to me what work there is to do here if you don't do the "higher difficulty" challenge.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also unclear to me what work there is to do here if you don't do the "higher difficulty" challenge.

This is just a higher difficulty exercise :)

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this is a great problem (implement promisify). The instructions could be more clear that you're expected to wrap errors in an Error object.

Feel free to provide a pull-request with this change.

Copy link
Author

@danvk danvk left a comment

Choose a reason for hiding this comment

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

OK, that's all my feedback. Thanks again for the great exercises, I had a blast doing them. I especially loved the stories about CEO and his/her friend Nick. Let me know if there's anything I can do to help with this project!

function GetIndexFn<T>(input: T[], comparator: (a: T, b: T) => number): number;
function GetElementFn<T>(input: T[], comparator: (a: T, b: T) => number): T | null;

export const getMaxIndex: typeof GetIndexFn;
Copy link
Author

Choose a reason for hiding this comment

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

It's interesting that you can use typeof Fn here to "inherit" the generic parameter. I don't think you can do such a thing with a type alias. (I tried above.)

Copy link
Collaborator

Choose a reason for hiding this comment

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

You can use type aliases like this

type MyFunction = <T>(arg: T): T;

@@ -1,13 +1,85 @@
export class Database<T> {
import * as fs from 'fs';
Copy link
Author

Choose a reason for hiding this comment

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

Exercise 13 was probably more work than all the others put together. I wonder if it could be split?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm thinking of redoing 13 and later exercises to focus more on types.

};

type Unionize<T extends object> = {[k in keyof T]: {k: k, v: T[k]}}[keyof T];
type QueryableKeys<T extends object> = Extract<Unionize<T>, {v: JsonScalar}>['k'];
Copy link
Author

Choose a reason for hiding this comment

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

This enforces that you only run queries on fields whose values are JsonScalar types. Maybe useful as an advanced extension checked with @ts-expect-error.

// Can't use any of these operators with array types.
expect(
// @ts-ignore
(await usersDatabase.find({grades: {$eq: 'Magical entity'}})).map(({_id}) => _id)
Copy link
Author

Choose a reason for hiding this comment

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

Since grades is array-valued, this shouldn't be allowed. It was actually a little unclear to me from the problem statement if non-scalars were to be allowed in records, but they're valid JSON, so I didn't see why not.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Only scalars are allowed in this implementation.

return words.every(w => r.$index[w]);
}
return Object.entries(q).every(
([k, v]) => matchOp(v as FieldOp, r[k as keyof T] as any)
Copy link
Author

Choose a reason for hiding this comment

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

I did use an any here. Sorry! :)

Copy link
Collaborator

Choose a reason for hiding this comment

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

You can make FieldOp a generic, i.e. FieldOp<T> and cast the other value to T.


constructor(filename: string, fullTextSearchFieldNames) {
constructor(filename: string, fullTextSearchFieldNames: (keyof T)[]) {
Copy link
Author

Choose a reason for hiding this comment

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

It would be nice to enforce that the fullTextSearchFieldNames correspond to string-valued properties in T. One way to do this is with the Objectify / Unionize generics I defined above (I'll be writing a blog post about these soon):

type StringKeys<T extends object> = Extract<Unionize<T>, {v: string}>['k'];
export class Database<T extends object> {
  constructor(filename: string, fullTextSearchFieldNames: StringKeys<T>[]) {
    // ...
  }
}

It's pretty neat to see this working in autocomplete!

image

}

async find(query, options?): Promise<T[]> {
Copy link
Author

Choose a reason for hiding this comment

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

It wasn't clear to me why this was declared async.

Copy link
Collaborator

Choose a reason for hiding this comment

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

To avoid synchronous file operations. You read the database when necessary.

}

async find(query, options?): Promise<T[]> {
return [];
async find(query: Query<T>, options?: Options<T>): Promise<Partial<T>[]> {
Copy link
Author

Choose a reason for hiding this comment

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

It would be a nice extension to have a more precise return type when options.projection is and is not specified. (It should be Promise<T[]> if it isn't and something less specific if it is.)

return rs;
}

async delete(query: Query<T>) {
Copy link
Author

Choose a reason for hiding this comment

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

Given the problem description, I was a little surprised that the tests didn't check that you wrote anything to disk. This problem felt like it had more to do with Node.js and less to do with TypeScript.

@@ -1,6 +1,6 @@
import {Database} from './database';
import * as path from 'path';
import {promises as fs} from 'mz/fs';
import {promises as fs} from 'fs';
Copy link
Author

Choose a reason for hiding this comment

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

I got runtime errors about fs.copyFile not being a function until I changed mz/fsfs.

@danvk danvk marked this pull request as ready for review May 8, 2020 00:25
@fireairforce
Copy link

Awesome~

@mdevils
Copy link
Collaborator

mdevils commented May 11, 2020

Glad you liked it, thanks for the comments.

mdevils added a commit that referenced this pull request May 11, 2020
@@ -95,9 +95,10 @@ function logPerson(person: Person) {
console.log(` - ${chalk.green(person.name)}, ${person.age}, ${additionalInformation}`);
}

function filterUsers(persons: Person[], criteria: User): User[] {
type Criteria = Partial<Omit<User, 'type'>>;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Partial and Omit are implemented using mapping. You can either use them directly or write a mapped type yourselves.

@@ -64,11 +64,18 @@ function logPerson(person: Person) {
);
}

function filterPersons(persons: Person[], personType: string, criteria: unknown): unknown[] {
function getObjectKeys<T extends object>(o: T): (keyof T)[] {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Happy to receive a copy if you can issue it to me.

@@ -64,11 +64,18 @@ function logPerson(person: Person) {
);
}

function filterPersons(persons: Person[], personType: string, criteria: unknown): unknown[] {
function getObjectKeys<T extends object>(o: T): (keyof T)[] {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Feel free to make a pull-request with this re-phrasing.

@@ -41,7 +41,7 @@ interface Admin {
role: string;
}

type PowerUser = unknown;
type PowerUser = Omit<User, 'type'> & Omit<Admin, 'type'> & {type: 'powerUser'};
Copy link
Collaborator

Choose a reason for hiding this comment

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

In the normal difficulty version you manually write PowerUser type without using neither User and Admin types.

@@ -71,8 +71,16 @@ type ApiResponse<T> = (
}
);

function promisify(arg: unknown): unknown {
return null;
function promisify<T>(fn: (callback: (arg: ApiResponse<T>) => void) => void): () => Promise<T> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Also unclear to me what work there is to do here if you don't do the "higher difficulty" challenge.

This is just a higher difficulty exercise :)


1. DateDetails interface is missing
time related fields such as hours, minutes and
time-related fields such as hours, minutes and
Copy link
Collaborator

Choose a reason for hiding this comment

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

Feel free to send me a pull-request with grammar fixes.

@@ -1,13 +1,85 @@
export class Database<T> {
import * as fs from 'fs';
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm thinking of redoing 13 and later exercises to focus more on types.

return words.every(w => r.$index[w]);
}
return Object.entries(q).every(
([k, v]) => matchOp(v as FieldOp, r[k as keyof T] as any)
Copy link
Collaborator

Choose a reason for hiding this comment

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

You can make FieldOp a generic, i.e. FieldOp<T> and cast the other value to T.

// Can't use any of these operators with array types.
expect(
// @ts-ignore
(await usersDatabase.find({grades: {$eq: 'Magical entity'}})).map(({_id}) => _id)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Only scalars are allowed in this implementation.

}

async find(query, options?): Promise<T[]> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

To avoid synchronous file operations. You read the database when necessary.

filipows pushed a commit to filipows/typescript-exercises that referenced this pull request May 16, 2020
export const getMaxElement: typeof GetElementFn;
export const getMinElement: typeof GetElementFn;
export const getMedianElement: typeof GetElementFn;
export const getAverageValue: typeof GetElementFn;
Copy link

Choose a reason for hiding this comment

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

I think getAverageValue should be different from others

export function getAverageValue<T>(input: T[], getValue: (a: T) => number): number | null;

Copy link

Choose a reason for hiding this comment

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

I am agree,there different comparator in getAverageVal

Choose a reason for hiding this comment

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

i think getAverageValue shouldn't be null

 export function getAverageValue<T>(
    input: T[],
    getValue: (item: T) => any
  ): number;
}

{$or: Query<T>[]} |
{$text: string} |
(
{[field in QueryableKeys<T>]?: FieldOp}
Copy link

Choose a reason for hiding this comment

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

The type can be improved by using distributive conditionals:
https://www.typescriptlang.org/docs/handbook/advanced-types.html#distributive-conditional-types

This can be used to disallow {} being passed as query. Ideally, it would also disallow passing multiple fields in one query, but TypeScript is not yet powerful enough to detect this.

Copy link

@fabb fabb Jun 1, 2020

Choose a reason for hiding this comment

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

https://github.com/fabb/typescript-exercises/blob/my_solutions/exercises/exercise-13/database.ts#L74

type Query<T> = FieldQuery<T> | AndQuery<T> | OrQuery<T> | TextQuery<T>

type FieldQuery<T, K extends keyof T = keyof T> = K extends string ? { [k in K]: Comparator<T[K]> } : never

@mdevils mdevils closed this Aug 13, 2020
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.

8 participants