Skip to content

Instantly share code, notes, and snippets.

@kuroski
Last active June 29, 2021 14:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kuroski/99d2d158d4365f6a899df44ae1704166 to your computer and use it in GitHub Desktop.
Save kuroski/99d2d158d4365f6a899df44ae1704166 to your computer and use it in GitHub Desktop.
Confident JS series
// utils.d.ts
type CamelCase<S extends string> = S extends `${infer P1}_${infer P2}${infer P3}`
? `${Lowercase<P1>}${Uppercase<P2>}${CamelCase<P3>}`
: Lowercase<S>
type CamelToSnakeCase<S extends string> = S extends `${infer T}${infer U}` ?
`${T extends Capitalize<T> ? "_" : ""}${Lowercase<T>}${CamelToSnakeCase<U>}` :
S
export type KeysToCamelCase<T> = {
[K in keyof T as CamelCase<string & K>]: T[K] extends {} ? KeysToCamelCase<T[K]> : T[K]
}
export type KeysToSnakeCase<T> = {
[K in keyof T as CamelToSnakeCase<string & K>]: T[K] extends {} ? KeysToSnakeCase<T[K]> : T[K]
}
// utils.js
// @ts-check
import humps from "humps"
/** @type {<T>(obj: T) => import(".").KeysToSnakeCase<T>} */
export const objectKeysToSnakeCase = humps.decamelizeKeys
/** @type {<T>(obj: T) => import(".").KeysToCamelCase<T>} */
export const objectKeysToCamelCase = humps.camelizeKeys
// humps.d.ts -> the name must match the library in this case
declare module "humps" {
function decamelizeKeys<T>(obj: T): import("./index").KeysToSnakeCase<T>;
function camelizeKeys<T>(obj: T): import("./index").KeysToCamelCase<T>;
}
// utils.js
// @ts-check
import humps from "humps";
export const objectKeysToSnakeCase = humps.decamelizeKeys;
export const objectKeysToCamelCase = humps.camelizeKeys;
import * as d from "decoders"
const ProductDecoder = d.object({
price: d.number,
vat: d.number
})
const ProductGuard = d.guard(ProductDecoder)
const rawData = JSON.stringify({
price: "10",
vat: 1.5
})
const api = () => Promise.resolve(JSON.parse(rawData)).then(ProductGuard)
function init() {
api().then((result) => {
console.log(`Total price is ${result.price + result.vat}`)
})
}
init()
import * as d from "decoders"
type Product = {
price: number
vat: number
}
const ProductDecoder: d.Decoder<Product> = d.object({
price: d.number,
vat: d.number
})
const ProductGuard: d.Guard<Product> = d.guard(ProductDecoder)
const rawData: string = JSON.stringify({
price: "10",
vat: "1,5"
})
const api = (): Promise<Product> => Promise.resolve(JSON.parse(rawData))
const calculateTotalPrice = (product: Product): number => product.price + product.vat
function init() {
api().then((result) => {
console.log(`Total price is ${calculateTotalPrice(result)}`)
})
}
import * as d from "decoders"
import objectKeysToSnakeCase from "./utils"
// It might seem redundant since we have the same structure in the decoder
// but some times, what you are sending to an API have a different data structure
const ProductEncoder = d.object({
price: d.number,
vat: d.number
})
const UserEncoder = d.object({
firstName: d.string,
lastName: d.string,
products: d.array(ProductEncoder)
})
const UserEncoderGuard = d.guard(
d.map(UserEncoder, objectKeysToSnakeCase) // 🦄 after everything is encoded, we can do further transformations
)
// then just use it
UserEncoderGuard({ your_user_object })
// types.d.ts
/**
* This is the default data structure for an appointment that is used in the entire application
* An Appointment can be postponed with a reason
* If we have a scheduled appointment, it will have a "to" and "from" dates
*/
export type Appointment = {
postponeReason: string | null
latestAppointment?: {
to: Date
from: Date
}
}
/**
* The API accepts a post request, but the payload vary depending on the Appointment
* If we are postponing, we must ONLY send "postpone_reason"
* If we are scheduling an appointment, then we must send a specific data structure
* If we send all data to the API, we receive an error (because some times, things are like that, right? 😂)
*/
export type AppointmentEncoded =
{ postpone_reason: string }
| { appointment: { from: Date } }
/**
* let's say our form have a business rule:
* "isPossible" is a flag, we have a boolean field in the form
* is the field is "true" then we must send only the "appointment" data in the request
* if the field is "false" then we must sent the "postponeReason"
*/
export type AppointmentFormData = {
isPossible: boolean
postponeReason: string
appointment: {
from?: Date
}
}
// codables/appointment.ts
import * as d from "decoders"
import { objectKeysToSnakeCase, objectKeysToCamelCase } from "./utils"
const appointmentDecoder: d.Decoder<Appointment> =
d.map(
d.object({
postpone_reason: d.nullable(d.string),
latest_appointment: d.maybe(d.object({
from: d.maybe(d.iso8601),
to: d.maybe(d.iso8601),
})),
}),
objectKeysToCamelCase
)
const appointmentEncoder: d.Decoder<AppointmentEncoded> =
d.map(
d.map(
d.object({
isPossible: d.boolean,
postponeReason: d.nullable(d.string),
appointment: d.object({
from: d.maybe(d.date)
}),
}),
(({
isPossible,
postponeReason,
appointment,
}) => isPossible ? { appointment } : { postponeReason })
),
objectKeysToSnakeCase
)
export default {
decode: d.guard(appointmentDecoder),
encode: (data: AppointmentFormData) => d.guard(appointmentEncoder)(data), // just to make sure "data" is actually comming from our form
}
// api-service.ts
import appointmentCodable from "codables/appointment"
export const fetchAppointment = (userId) => axios.get(`url-to-api/${userId}/appointment`).then(appointmentCodable.decode)
export const saveAppointment = (userId, data: AppointmentFormData) => axios.post(`url-to-api/${userId}/appointment`, appointmentCodable.encode(data)).then(appointmentCodable.decode)
const rawData = JSON.stringify({
price: "10",
vat: 1.5
})
const api = () => Promise.resolve(JSON.parse(rawData))
const calculateTotalPrice = (product) => product.price + product.vat
function init() {
api().then((result) => {
console.log(`Total price is ${calculateTotalPrice(result)}`) // 11.5
})
}
init()
// index.d.ts
/**
* This is the default data structure for an appointment that is used in the entire application
* An Appointment can be postponed with a reason
* If we have a scheduled appointment, it will have a "to" and "from" dates
*/
export type Appointment = {
postponeReason: string | null
latestAppointment?: {
to: Date
from: Date
}
}
/**
* The API accepts a post request, but the payload vary depending on the Appointment
* If we are postponing, we must ONLY send "postpone_reason"
* If we are scheduling an appointment, then we must send a specific data structure
* If we send all data to the API, we receive an error (because some times, things are like that, right? 😂)
*/
export type AppointmentEncoded =
{ postpone_reason: string }
| { appointment: { from: Date } }
/**
* let's say our form have a business rule:
* "isPossible" is a flag, we have a boolean field in the form
* is the field is "true" then we must send only the "appointment" data in the request
* if the field is "false" then we must sent the "postponeReason"
*/
export type AppointmentFormData = {
isPossible: boolean
postponeReason: string
appointment: {
from?: Date
}
}
// codables/appointment.js
import * as d from "decoders";
import { objectKeysToSnakeCase, objectKeysToCamelCase } from "./utils";
/**
* We can use @typedef to define types at application or file level
* Bellow we are importing the types from our declaration file and attributing the type to a variable
* The annotation is @typedef {your-type-here} NameOfTheVariable
*
* @typedef {import("./index").Appointment} Appointment
* @typedef {import("./index").AppointmentEncoded} AppointmentEncoded
* @typedef {import("./index").AppointmentFormData} AppointmentFormData
*
* Now, we can use directly "Appointment", "AppointmentEncoded", "AppointmentFormData" without having to import every time
*/
// You can use types from other libraries normally
// Remember that the @type annotation is similar to @typedef
// @type {your-type-here}
/** @type {d.Decoder<Appointment>} */
const appointmentDecoder = d.map(
d.object({
postpone_reason: d.nullable(d.string),
latest_appointment: d.maybe(
d.object({
from: d.maybe(d.iso8601),
to: d.maybe(d.iso8601),
})
),
}),
objectKeysToCamelCase
);
/** @type {d.Decoder<AppointmentEncoded>} */
const appointmentEncoder = d.map(
d.map(
d.object({
isPossible: d.boolean,
postponeReason: d.nullable(d.string),
appointment: d.object({
from: d.maybe(d.date),
}),
}),
({ isPossible, postponeReason, appointment }) =>
isPossible ? { appointment } : { postponeReason }
),
objectKeysToSnakeCase
);
// You are free to type in any way your files, bellow, I could have created a type for the entire object
// Or I can provide a type per property
export default {
decode: d.guard(appointmentDecoder),
/**
* I can choose to use this annotation to document/type the function params and return
* Bellow you will see a different way to document
*
* @param {AppointmentFormData} data
* @returns {AppointmentEncoded}
*/
encode: (data) => d.guard(appointmentEncoder)(data), // just to make sure "data" is actually comming from our form
};
// api-service.ts
import appointmentCodable from "codables/appointment";
export const fetchAppointment = (userId) =>
axios.get(`url-to-api/${userId}/appointment`).then(appointmentCodable.decode);
// Here you can also provide the entire function type, the way I did bellow is equivalent from the "encode" documentation above
// You could also extract the function annotation into a declaration file if you prefer
/** @type {(userId: number, data: AppointmentFormData) => Promise<Appointment>} */
export const saveAppointment = (userId, data) =>
axios
.post(`url-to-api/${userId}/appointment`, appointmentCodable.encode(data))
.then(appointmentCodable.decode);
import Vue from "vue"
import { fireEvent, render, screen, waitFor } from "@testing-library/vue";
import userEvent from "@testing-library/user-event";
import Element from "element-ui";
import { storeConfig } from "@/store";
import UserView from "@/views/UserView.vue";
import mockServer from "../mockServer";
import githubUserDecoder from "@/codables/githubUserDecoder";
describe("UserView", () => {
const build = () => {
const view = render(UserView, { store: storeConfig }, (vue) => {
vue.use(Element);
});
return {
view,
};
};
test("a user can search for Github usernames", async () => {
const server = mockServer();
const octocat = githubUserDecoder(server.schema.first("user")?.attrs);
build();
userEvent.type(
screen.getByPlaceholderText("Pesquise o usuário"),
String(octocat.login)
);
await Vue.nextTick();
userEvent.click(screen.getByRole("button"));
await waitFor(() => expect(screen.getByText(String(octocat.name))));
expect(screen.getByAltText(String(octocat.name))).toHaveAttribute(
"src",
octocat.avatarUrl
);
expect(screen.getByText(String(octocat.bio))).toBeInTheDocument();
});
});
type Product = {
price: number
vat: number
}
const rawData: string = JSON.stringify({
price: "10",
vat: "1,5"
})
const api = (): Promise<Product> => Promise.resolve(JSON.parse(rawData))
const calculateTotalPrice = (product: Product): number => product.price + product.vat
function init() {
api().then((result) => {
console.log(`Total price is ${calculateTotalPrice(result)}`)
})
}
/**
* @typedef {import("./index.d").Product} Product
* @typedef {import("./index.d").AppointmentFormData} AppointmentFormData
* @typedef {import("./index.d").Appointment} Appointment
*/
// PROPS
/**
* Make use of the `PropOptions` to type your props properly
*
* @type {import('vue').PropOptions<YOUR_TYPE>}
*/
props: {
/** @type {import('vue').PropOptions<Product>} */
product: {
...
}
},
// COMPUTED PROPERTIES
/**
* They are just functions, you can explicitly apply the return value
*
* @return {YOUR_TYPE}
*/
computed: {
/** @return {number} */
totalPrice() {
return this.product.price + this.product.vat;
}
}
// METHODS
/**
* Same thing, but you can specificy parameters
*
* @param {YOUR_TYPE} paramName - param description
* @return {YOUR_TYPE}
*/
methods: {
/**
* @param {AppointmentFormData} formData
* @return Promise<Appointment>
*/
onSubmit(formData) {
return this.product.price + this.product.vat;
}
}
// DATA
/**
* Since data is also a function, you can choose to create a entire type for its return values
* or you can even type each property individually
* Remember that we have type inference, so a few items don't actually need to be typed
*/
data: () => ({
/** @type {boolean} - this does not need to be typed, but you can do it anyway */
isElementVisible: false,
/** @type {AppointmentFormData} */
form: {
// provide all AppointmentFormData fields, otherwise you will get compile time errors
}
})
// STORE
/**
* Vuex does not have a good support for types in Vue 2
* But we can still provide a few things like the state
*/
// index.d.ts
export type State = {
[K in keyof Appointment]?: Appointment[K];
};
// appointment-module.js
export default {
namespaced: true,
/** @type {import("./index.d").State} */
state: {
postponeReason: undefined,
latestAppointment: undefined
},
actions: {
/**
* @param {import('vuex').ActionContext<State, State>} context
* @param {import('./index.d').Appointment} appointment
* @returns
*/
myAction(context, appointment) {
// do something
},
}
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment