No matter how well that's done, it creates friction somewhere in the process interacting with existing code.
That is, I can agree that an inherently strongly typed language has benefits, while also being skeptical about bolted on additions.
A big chunk of my job is writing node microservices in AWS Lambda. I do everything I can to avoid shared library code, since past experience tells me there be lots of dragons (mainly in when and how to push or pull lib updates to components). I have a very tiny shared lib that I try to never touch and definitely never introduce breaking changes.
Unit tests are a breeze since I never have to cast objects or worry about generics, etc.
Typescript would slow me down so much and add absolutely no benefit. Maybe I'm misinterpreting though and no one is claiming Typescript would benefit here.
We also have some C# lamdbas and I find writing unit tests for those so much more of a pain - since the shared libs have generics and I'm always casting things. But admittedly I don't know all the tricks.
This indicates to me that you're trying to write code that isn't correct (not doesn't work, but rather only works because of implicit couplings between components) and/or doing exotic lisp-style metaprogramming.
In the latter case, yeah, C#'s type system isn't powerful enough. Others are (to an extent: arbitrary code execution at compile time is never going to be completely safe).
In the former case ... that should be difficult. Forcing you to be explicit is half the point of a type system.
No, that's what sum types are for.
Probably there's a better way. I'm not a C# expert. But I was a Java dev for 10 years so it's not super unfamiliar.
Generics reduce the amount of things you must care about on your tests.
And you shouldn't cast objects in almost no code ever. Most 100k LoC programs won't need it even once, your microservices should need it proportionally less.
That's the thing. The gains grow superlineraly with the amount of code. They make it just a bit easier to write some trivial 100's LoC programs, and they make it possible at all to have a working 100k LoC system. But if you don't learn them, you won't know where the break-even point is for you.
That's a good way to put it.
- Define all app state as a snapshot. Problem: snapshot can become stale, so more infra might be necessary to make sure that snapshot is up to date;
- Pass only the necessary state and construct as necessary. Problem: hard to define whole state precisely and ensure that it conforms to runtime state of a healthy app;
- Pass a subset of necessary state for some execution branch and cast the type. Problem: casting may result in test failures during runtime and potentially other issues such as modify-run-fail debug loop;
- Mock return values of functions called within the function being tested and use any combination of "state passing options above".
In a lot of places I use such approach with custom type helpers and transitive types, and passing in only the necessary subset for smaller functions or mocking return values for bigger ones. What do you think? I know that the AppState can be defined as a union of possible states and together with type guards can address those issues better. I just wanted to hear your opinion on how you would address such problems. I hope I explained it well enough.
export type Fn = (...params: any) => any;
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
export type FirstParamType<G> = G extends Fn[]
? UnionToIntersection<Parameters<G[number]>[0]>
: G extends Fn
? Parameters<G>[0]
: never;
export interface AppState {
first: {
a: number;
b: number[];
};
second: {
c: string;
d: string[];
}
}
type DeepPick<A, B extends keyof A, C extends keyof A[B]> = { [BK in B]: Pick<A[B], C> };
function calculateUsingFirstB(state: DeepPick<AppState, "first", "b">): number[] {
return state.first.b; // some calculation
}
function calculateUsingSecondC(state: DeepPick<AppState, "second", "c">): string {
return state.second.c; // another calculation
}
// function which takes complex state parameter and calculates the result based on results of other functions
function calculateMore(state: FirstParamType<[typeof calculateUsingFirstB, typeof calculateUsingSecondC]> & DeepPick<AppState, "first", "a">): string | number[] {
if (state.first.a > 10) {
return calculateUsingFirstB(state);
}
return calculateUsingSecondC(state);
}If not all app state is necessary for that function, it shouldn't require the whole app state in its arguments.
type AppState = { databaseConnection: DatabaseConnection, env : "dev" | "prod", apiToken: string, userId: string, ... } & SomeOtherStuff
const dropTables = (app: Pick<AppState, "databaseConnection"> & {env: "dev"} ) => app.databaseConnection.dropTables()
Conversely, if a function you don't control says it needs the whole app state, believe it.I'm a big fan of WET - write everything twice. Then maybe the 3rd of 4th time worry about creating some new abstraction to share code. It's so much easier to add an abstraction later when it becomes obviously needed - than to remove one when you find out your three things actually need different variations on the lib code.